Somewhere in the early days of many teams, someone generated a key on the CI server and added it to a developer’s GitHub account “just to get the build working.” That key now has that person’s full access to every repo they can touch - readable by anyone who can read the CI config. This is the access equivalent of the committed-secret problem in a secret got committed, now what, and it deserves the same urgency.
Deploy keys: read-only, one repo#
A deploy key is an SSH key attached to a single repository, not to a person. GitHub and GitLab both support them, and both let you make the key read-only.
Use one when CI needs to clone one repo: your app repo for the test pipeline, the deployment target pulling code on the server. Generate a dedicated keypair on the machine, add the public half as a deploy key with read-only access, and revoke it by deleting the key - no person’s account involved. If you followed our SSH keys guide, this is the same mechanics, pointed at a repo instead of a user.
The limitation is real: one deploy key cannot span repos. If CI must pull private Composer or npm packages from sibling repositories, a deploy key per repo works but multiplies keys fast.
Machine users and fine-grained tokens#
When CI genuinely needs several private repos - a Laravel app plus two private packages - the options widen:
- A machine user: a separate GitHub account that exists only for automation, granted read access to exactly the repos CI needs. Simple to reason about, and access is visible in the repo’s collaborator list. Costs a seat on some plans.
- A fine-grained personal access token: scoped to specific repositories, read-only contents permission, with an expiry date. Lighter than a machine user, and the expiry forces regular rotation. Use the shortest expiry your rotation process can keep up with.
What both avoid is the original sin: a credential tied to a human’s full access, valid forever, sitting in a CI variable.
The rules that matter#
Whatever mechanism you pick, three rules keep it healthy:
- Read-only by default. CI that only clones needs no write access. Grant push only to jobs that genuinely push (a release workflow tagging a version), and only on that job.
- One credential per consumer. The staging server’s key and the CI key are different keys, so revoking one never breaks the other and a leak has a smaller blast radius.
- Rotation you can actually do. A credential you cannot rotate without a meeting will never be rotated. Keep a short note of where each key lives; the day one leaks, you want a five-minute fix, not archaeology.
Access for automation is a solved problem - the only wrong answer left is your personal key in a CI variable.
Share this post
Keep reading
Annotating Commits with git notes: Metadata Without Rewriting History
Some things belong on a commit but not in it: review links, benchmark results, the ticket nobody remembered to reference. git notes adds them after the fact, no rebase required.
Backing Up Your Git Server So It Actually Restores
Every clone is a partial backup - but hooks, config and server state live outside the repo. Mirror, bundle, copy off-site, and rehearse the restore.
Cherry-Picking: One Commit Without the Branch
Cherry-pick copies a single commit onto your branch and leaves the rest behind. Right for hotfixes across release branches, wrong as a sync strategy.