Git, CI/CD & GitOpsXXXIV · Git SecuritySSHKeys
SSH keys and deploy keys — personal keys, deploy keys, host keys, and the read-only distinction
What you'll learn
- Distinguish personal account keys, deploy keys, and host keys by scope and lifetime
- Generate an ed25519 SSH key pair with ssh-keygen and load it into the agent with ssh-add
- Configure a read-only deploy key for a CI runner and a read/write deploy key for a deploy host
- Recognise the failure modes of key reuse, key-without-passphrase on shared hosts, and unrotated keys
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
SSH authentication for Git is built from three classes of key, each with a different scope and a different lifetime: the personal account key, the deploy key, and the host key. The class is the scope: a personal key has the rights of the account, a deploy key is scoped to one repository, and a host key authenticates the server rather than the client. Confusing the three classes is the most common SSH mistake in an infrastructure repository.
Personal account keys
A personal account key is an SSH key pair whose public half
is registered against a user’s account on the forge. The
forge treats a successful challenge with that key as
“this user is acting”, and the user can clone, fetch, push,
and merge against every repository their account can see.
The private half lives on the engineer’s workstation,
typically at ~/.ssh/id_ed25519 or under a named path, and
is loaded into the SSH agent so the engineer does not
re-enter the passphrase for every push.
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519
ssh-add ~/.ssh/id_ed25519
ssh -T git@github.com
The diagnostic ssh -T git@${GIT_HOST} connects to the forge
and confirms that the key registered against the engineer’s
account is the key the agent is presenting. A successful
response names the account and notes that the forge does
not provide shell access; any other response (or a
Permission denied (publickey)) is a key-or-account
mismatch and the place to start debugging.
The personal account key is the right choice for engineer workstations where the engineer’s account is the right scope. It is the wrong choice for any machine that should not inherit the engineer’s permissions — a CI runner, a deploy host, a shared bastion.
Deploy keys
A deploy key is an SSH key pair whose public half is registered against a single repository on the forge. The forge treats a successful challenge as “the holder of this key is acting on this one repository”, and the holder can either read the repository or read and write it depending on how the deploy key was added. The deploy key does not inherit any account’s permissions; it has exactly the rights granted at registration time.
flowchart LR
A["deploy key pair"] --> B["public half registered on repo X"]
B --> C{"scope at registration"}
C -->|read-only| D["git clone works"]
C -->|read-only| E["git push refused"]
C -->|read/write| F["git clone works"]
C -->|read/write| G["git push works"]
Two scoping options, set at registration:
- Read-only. The key can clone and fetch but cannot push. This is the right scope for a CI runner that only needs to check out the repository to run builds and tests. A read-only deploy key cannot be used to push a malicious commit to the default branch.
- Read/write. The key can clone, fetch, and push. This is the right scope for a deploy host that pulls and pushes infrastructure state, or for a release pipeline that cuts signed tags.
The discipline is to register a deploy key with the narrowest scope the use case actually requires. A CI runner that only needs to check out code wants a read-only deploy key, not a read/write one; a deploy host that only needs to clone wants a read-only deploy key plus a separate write path for the deploy step. The blast radius of a deploy-key compromise is the scope of the deploy key — read-only deploy keys on production repositories cannot push, by construction.
# On the CI runner
ssh-keygen -t ed25519 -f ~/.ssh/deploy_readonly
ssh-add ~/.ssh/deploy_readonly
# The public key (~/.ssh/deploy_readonly.pub) is registered
# against the repository at Settings -> Deploy keys -> Read-only
A single SSH key cannot be a deploy key for multiple
repositories in GitHub — each repository requires its own
key registration. For multi-repository deploy access from a
single machine, the patterns are: one key per repository
and Host aliases in ~/.ssh/config to keep the URLs
clean; or a forge-side machine user with a personal account
key that has been granted access to each repository.
Host keys
A host key is the SSH server’s identity key: it
authenticates the server to the client during the initial
handshake. The client checks the server’s host key against
~/.ssh/known_hosts before sending any authentication
material; a host key that does not match is either a first
connection (the client prompts to add the key) or a
man-in-the-middle attempt (the client refuses the
connection).
The host key is the forge’s identity, not the client’s. Compromising the host key lets an attacker impersonate the forge to clients; compromising a client key lets an attacker impersonate the client to the forge. The two threats are different and the responses are different: a host-key compromise is a forge-side incident; a client-key compromise is the per-client incident response covered in XXXIV-05.
Generation, passphrase, and rotation
Every SSH key for a production use case should be:
- ed25519.
ssh-keygen -t ed25519is the modern algorithm; it is shorter than RSA for the same security level and has better performance. - Passphrase-protected. A key without a passphrase is a credential whose decrypted form lives on disk; a passphrase-protected key whose decrypted form lives only in the agent is a credential that requires the agent’s owner to use it.
- Rotated on a schedule. The rotation cadence is the team’s policy: quarterly for personal account keys, per-deploy for deploy keys on long-lived hosts, and immediately on any suspected compromise.
ssh-keygen -t ed25519 -f ~/.ssh/deploy_production
ssh-add ~/.ssh/deploy_production
Rotation is a four-step dance: generate the new key, add it to the forge (account keys: register against the account; deploy keys: add to the repository with the right scope), update every client to load the new key, and revoke the old key once every client is migrated. The oldest-key-first removal is what stops an unrotated key from being a permanent credential on a lost laptop.
Production discipline
- Use ed25519 for every new key.
ssh-keygen -t ed25519is the modern default; legacy RSA keys can be kept but new keys should be ed25519. - Passphrase every key that touches a shared host. A key without a passphrase on a shared runner is a credential that any process on the runner can read.
- Register the deploy key with the narrowest scope the use case allows. Read-only for CI runners; read/write only when the machine must push.
- Provision
known_hostson every production deploy host.StrictHostKeyChecking yesand a populatedknown_hostsis the right default for a host that cannot ask. - Rotate keys on a schedule and on any suspected compromise. The rotation cadence is the team’s policy; the immediate rotation is non-negotiable.
Cross-course references
- Git, CI/CD & GitOps — Part XXXIV-01 (Authentication options) — the SSH-vs-HTTPS framing that this lesson extends.
- Git, CI/CD & GitOps — Part XXVI-06 (Signing configuration) — SSH signing reuses the same key pair; the rotation policy applies to both.
- Git, CI/CD & GitOps — Part XXXIII-06 (Signing policy) — the joiners-movers-leavers process that governs key removal.
- Linux for Production Sysadmins — Part XII (RepositorySecurity) — the apt/dnf trust-store pattern that mirrors the deploy-key pattern.
Quiz
Knowledge check · 4 questions
Q1. A CI runner needs to check out an infrastructure repository to run terraform plan in pull-request builds. The runner must not be able to push to the repository under any circumstance. Which SSH key class and scope is the right choice?
Q2. A single SSH key can be registered as a deploy key against multiple repositories on GitHub and will work against all of them once registered.
Q3. Name the three classes of SSH key used in Git workflows and the scope each one grants on a successful challenge.
Q4. Diagnose why a CI runner is pushing commits to the production repository, and recommend the deploy-key rotation that contains the incident.
A team operates a CI runner that builds and tests pull requests. The runner authenticates to the forge with a deploy key registered against the production repository. The deploy key was registered with read/write scope because the team did not think carefully about it at registration time; the runner only needs to check out code. An attacker compromises the runner via a vulnerable build step, finds the deploy key, and pushes a malicious commit to a feature branch. Branch protection refuses the merge, but the attacker has now demonstrated write access to the production repository — a far worse blast radius than the runner's actual job required.
Passing score: 75%. Answers are checked in this browser.