· 6 min read

SSH deploy keys versus personal keys

A per-repo deploy key with read-only access is the only SSH key a production server should ever hold. Your personal key isn't part of the deploy.

By Marten

I have dropped personal SSH keys onto production servers more times than I want to admit. It is one of those practices that starts as “I’ll clean this up later” and ends as a team-wide ritual that nobody questions, because nobody knows when it started.

The first time it bit me was during an offboarding. A developer had left, we rotated their GitHub access, and ten minutes later three production servers failed to pull updates because their deploy used the developer’s personal SSH key. Nobody had documented this. The developer had set it up during their first week, two years earlier.

This post is not about that specific incident. It is about why a personal SSH key on a production server is always the wrong answer, and what to use instead.

The two-second summary

A personal SSH key represents “a human with permissions to do things.” A deploy key represents “a specific repo, read-only, no other access.” Those are different roles, with different lifecycles, and they should never be the same key.

If the answer to “how does the production server pull code” is “via my personal key”, you have a personal key on a server you cannot control, with access to every private repo your account can reach, and a renewal schedule that depends on you remembering to keep it in sync.

That is more power than the server should have. It is also a bigger blast radius than you want.

What a deploy key is

A deploy key, in GitHub terminology, is an SSH public key that you attach to a single repository. Any machine with the matching private key can clone or fetch from that one repo. It cannot access any other repo. It cannot push (unless you check the “allow write access” box, which you almost never should). It cannot act on behalf of a user — there is no GitHub account associated with it.

You add it in the repository settings under “Deploy keys.” You generate the key pair on the server that is going to use it. The private key never leaves that server. The public key goes into GitHub, with a name that identifies which server it is for (“prod-web-01 deploy key”).

A machine can only hold one deploy key per repository, because GitHub scopes deploy keys per-repo at key level. This sounds limiting until you realise that a production server almost never needs to pull from more than one repo. If it does, you use multiple deploy keys, one per repo.

What goes wrong with personal keys

Three failure modes I have hit personally.

One, offboarding. You rotate someone’s access and their laptop loses the ability to push. Fine. But their personal key is still sitting in ~/.ssh/authorized_keys on four servers, and on those servers it is configured as the key that pulls from GitHub during deploy. Nobody put it there deliberately; it was copy-pasted during initial setup. When the person’s GitHub account is removed, the keys still work against GitHub until they are manually revoked, and there is no audit trail to tell you which keys are doing what.

Two, access creep. A developer’s personal key has access to every private repo their GitHub account can reach. If that key is on a server, that server effectively has access to every private repo the developer’s account can reach. Adding a new repo to the organisation extends the server’s implicit access, even if nobody asked for that. You end up with servers that can clone your internal HR repo because the deploy user happened to be in a group that has read access.

Three, key rotation. When you rotate your personal SSH key (which you should, every so often), you now have to go remember which servers have the old one, and update all of them. If you forget one, deploys break silently the next time that server runs a pull. Nobody gets notified because the failure mode is “cron ran, exited non-zero, stderr went to /dev/null.”

Deploy keys avoid all three, because they are scoped to the server and the repo. Rotating them only affects that server. Offboarding a person has no effect on them. New repos in the organisation do not extend their access, because they do not have organisation-level permissions to begin with.

How to set one up

On the server, as the user that will run the deploy:

ssh-keygen -t ed25519 -C "prod-web-01 deploy key for example-app" -f ~/.ssh/deploy_example_app -N ''

No passphrase. An automated deploy cannot enter a passphrase, so the options are “no passphrase” or “use ssh-agent with a stored unlock.” For a machine key on a server you control, no passphrase is the honest answer.

The public key is in ~/.ssh/deploy_example_app.pub. Copy its contents, go to the GitHub repo’s Settings → Deploy keys → Add deploy key, paste, name it something like “prod-web-01”, leave “allow write access” unchecked unless you have a specific reason.

Then tell SSH to use this key when connecting to GitHub for this repo. In ~/.ssh/config:

Host github-example-app
  Hostname github.com
  User git
  IdentityFile ~/.ssh/deploy_example_app
  IdentitiesOnly yes

And clone using the host alias:

git clone git@github-example-app:myorg/example-app.git /srv/www/example.com

From here, git pull in that directory will use the deploy key. Other repos on the same server can have their own aliases with their own keys.

When deploy keys aren’t enough

Deploy keys cover the “pull code from a repo” case. If your deploy also needs to push (for instance, to tag a release), deploy keys are awkward because you have to enable write access on the key, and now the blast radius of that key leaking is worse.

For pushing from a server, I use a GitHub App with a scoped personal access token or a machine user. Not my personal account. If I need “this server can push to this one repo,” I set up a GitHub App with just that permission, installed on just that repo, and the server uses the app’s token. The token expires, rotates automatically, and is not tied to a human.

Most deploys do not need push. If yours does, question whether it should. Tags and releases can be made during the CI step that builds the artifact, not on the deploy server.

The rule

A production server has exactly two kinds of key material: a deploy key per repo it needs to read, and a host key that identifies the server itself. It has no user-scoped credentials. If you SSH into the server, you bring your own credentials with you; you do not find them there when you arrive.

The test is easy. Walk away from a server for three months, come back, and ask: if every human who set this server up vanished, could the deploy still run? If yes, the keys are scoped correctly. If no, the server is holding credentials that belong somewhere else.