In this tutorial, I will show you step by step how to connect Jenkins to a private GitHub repository with least-privilege credentials — a per-repo deploy key, a read-only fine-grained PAT, and a push webhook — including the two mistakes that each cost a failed build in a real setup.
The three credentials and their jobs
| Credential | Owner | Used for |
|---|---|---|
| SSH deploy key | deploy user on the server |
git pull during deploys |
| Fine-grained PAT | Jenkins | checkout + GitHub API (scan, statuses) |
| Webhook | GitHub → Jenkins | instant builds on push |
Step 1 — Per-repo deploy key (a convention worth stealing)
One key per repo, named accordingly, with an SSH host alias so multiple repo keys coexist on one server:
# as the deploy user on the server
ssh-keygen -t ed25519 -f ~/.ssh/id_repo_myapp -N "" -C "deploy@myapp"
cat >> ~/.ssh/config <<'EOF'
Host github-repo-myapp
HostName github.com
User git
IdentityFile ~/.ssh/id_repo_myapp
IdentitiesOnly yes
EOF
Add the public key in GitHub → repo → Settings → Deploy keys (read-only), then verify and clone through the alias:
ssh -T git@github-repo-myapp # "successfully authenticated"
git clone github-repo-myapp:acme/myapp.git
Step 2 — The fine-grained PAT (minimal, but watch the expiry)
GitHub → Settings → Developer settings → Fine-grained tokens:
- Repository access: Only select repositories → your repo.
- Permissions: Contents: Read-only (Metadata is added automatically). That's all a checkout needs.
- Expiration: the default is 30 days — your pipeline will die silently in a month. Choose Custom and set ~1 year, with a reminder before it lapses.
Store it in Jenkins as Username with password: username = your GitHub username, password = the token, ID = github-token.
Gotcha #1 — credential ID is not the username
We also store the CI database login in Jenkins. First build failed every single test with:
SQLSTATE[HY000] [1045] Access denied for user 'ci-mysql'@'localhost'
ci-mysql was the credential's ID, not the database username — the dialog had been filled with the ID in the username field. When a Jenkinsfile does credentials('ci-mysql'), the ID is only the lookup key; the username/password fields are what get injected. If your logs show your credential ID where a username should be, that's the tell.
Step 3 — The multibranch job, and gotcha #2
New Item → Multibranch Pipeline → Branch Sources → GitHub → credentials github-token, HTTPS repo URL. Save triggers the first scan — which immediately failed:
ERROR: Could not fetch branches from source
"message":"Resource not accessible by personal access token","status":"403"
… Failed to retrieve …/pulls?state=open
The default behaviors include Discover pull requests from origin/forks, which call the PR API — and a Contents-only token can't. Least privilege collided with default assumptions. Two clean fixes:
- No PR workflow (our case): in Behaviors, delete both Discover pull requests entries and set Discover branches → All branches (the default "exclude branches filed as PRs" strategy also touches the PR API).
- PR workflow: add Pull requests: Read-only to the PAT instead.
Rescan: 'Jenkinsfile' found — Scheduled build for branch: master.
Step 4 — The webhook
GitHub repo → Settings → Webhooks → Add webhook:
- Payload URL:
https://jenkins.example.com/github-webhook/(trailing slash matters) - Content type:
application/json - Events: Just the push event
GitHub sends a ping on save — look for the green tick under Recent Deliveries. From now on every push builds within seconds; keep the periodic scan off unless you need branch-deletion cleanup.
Next in the series: post #6 is the Jenkinsfile itself — the full stage graph for a Laravel + React app, the CI MySQL user with its dual-host grant quirk, and why withoutVite() belongs in your base TestCase.
Originally published at dineshstack.com — read the full version with code samples and updates there.
Top comments (0)