Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXCIII · Credential RotationDeployKeys

Deploy keys and SSH key rotation — the lifecycle of a read/write credential for a Git remote

Advanced⏱ ~25 min🧪 Lab requiredgitopenssh

What you'll learn

  • Distinguish read-only deploy keys from read/write deploy keys by their use case
  • Generate an SSH keypair with the modern ed25519 algorithm
  • Execute a four-phase rotation: install new, cut over, revoke old, retain for audit
  • Remove a deploy key from a GitHub repository using the gh CLI

Prerequisites

Practice

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

Not yet marked complete on this device.

A deploy key is an SSH public key installed on a repository with a single purpose: to let one machine pull from or push to that repository without holding a personal account. The lifetime of a deploy key is finite, the rotation is a four-phase lifecycle, and skipping any phase leaves an orphan credential that the team has stopped auditing. This lesson walks the lifecycle and the CLI commands that execute each phase.

Read-only versus read/write

Deploy keys come in two modes:

  • Read-only. The key can clone and fetch but cannot push. Used by CI runners that build from the repository without ever writing to it.
  • Read/write. The key can push. Used by automation that promotes tags, opens pull requests, or writes back to the repository (for example, a bot that updates a changelog).

The default is read-only. Read/write is a deliberate choice, because every push-capable credential is also a force-push-capable credential. The blast radius of a leaked read/write key is the repository history itself.

Phase 1: Generate

The modern algorithm is ed25519; it produces a short key with strong entropy and is the default on current OpenSSH releases. The legacy RSA-2048 algorithm still works but is not preferred.

ssh-keygen -t ed25519 -C "deploy-key-ci-runner-2026-q1" \
  -f "$HOME/.ssh/deploy_key_ed25519_2026q1" -N ""

The -C flag sets the comment that the forge will display in its deploy-keys list; the comment is the only human-readable label on the key and is worth making descriptive. The -N "" flag sets an empty passphrase - appropriate for a service key that an automation needs to use without interaction, but not appropriate for a personal key.

Phase 2: Install

The public key is installed on the repository. The private key is installed on the runner. The two installations are separate steps with separate tooling.

gh repo deploy-key add \
  "$HOME/.ssh/deploy_key_ed25519_2026q1.pub" \
  --repo "$ORG/$REPO" \
  --title "ci-runner-2026-q1" \
  --read-only

The gh repo deploy-key add command installs the public key on the repository. The --read-only flag sets the mode; the absence of the flag defaults to read-only as well. The title is what the forge UI shows in the deploy-keys list; it is worth dating.

cp "$HOME/.ssh/deploy_key_ed25519_2026q1" \
   "$RUNNER_SSH_DIR/deploy_key"
chmod 600 "$RUNNER_SSH_DIR/deploy_key"

The private key is copied to the runner with mode 0600. The $RUNNER_SSH_DIR shell variable points to the runner’s SSH directory. The install on the runner is what makes the key actually work; the install on the forge is what authorises it.

Phase 3: Cut over

The cutover is the moment the runner stops using the old key and starts using the new one. For a runner that loads its SSH config from a managed directory, the cutover is a config change followed by a reload.

mv "$RUNNER_SSH_DIR/deploy_key" \
   "$RUNNER_SSH_DIR/deploy_key.archived.2025q4"

mv "$RUNNER_SSH_DIR/deploy_key.ed25519.2026q1" \
   "$RUNNER_SSH_DIR/deploy_key"

systemctl reload ssh-agent

The old private key is renamed with an .archived suffix that records the quarter. The new key is renamed to the canonical name. The SSH agent is reloaded so its in-memory key list updates. A git ls-remote against the repository verifies the cutover before any production workload picks up the change.

Phase 4: Revoke and retain

The final phase is the one teams skip. The old key must be removed from the repository. If it is not removed, the forge still accepts it, and a leaked old key is still valid.

gh repo deploy-key list --repo "$ORG/$REPO"
gh repo deploy-key remove "$OLD_KEY_ID" --repo "$ORG/$REPO"

The first command lists the deploy keys and their IDs. The second removes the old key by ID. After removal, an attempt to push or pull with the old key fails with an authentication error - which is the desired behaviour, because it confirms the key is no longer authorising anything.

flowchart TD
    A["Generate keypair"] --> B["Install public on forge"]
    B --> C["Install private on runner"]
    C --> D["Verify with git ls-remote"]
    D --> E["Cut over: rename, reload agent"]
    E --> F["Verify with production workload"]
    F --> G["Revoke old key from forge"]
    G --> H["Retain old private key in audit archive"]

Production discipline

  1. Default to ed25519. Shorter key, stronger algorithm, better defaults.
  2. Default to read-only. Read/write is a deliberate choice with documented justification.
  3. The cutover PR removes the old key, not just adds the new one. Revocation is part of the same change.
  4. Retain old private keys in an audit archive. A rotated key is still useful for forensics if a leak is later discovered.

Cross-course references

  • This course, Part XXXIV-02 (SSH keys and deploy keys) covers the underlying key format and the ~/.ssh/config mechanics.
  • This course, Part XCIII-06 (GitOps controller credential rotation) covers the same lifecycle applied to a controller’s repository access.
  • Linux for Production Sysadmins, Part VII (SSH hardening) covers the SSH-layer key controls that rotation operates within.

Quiz

Knowledge check · 4 questions

  1. Q1. A team rotates a deploy key by installing the new public key on the forge, copying the new private key to the runner, and pointing the runner's SSH config at the new key. They do not remove the old key from the repository. What is the resulting risk?

  2. Q2. The deploy key credential is the public key; rotating it requires replacing the public key on the forge only.

  3. Q3. List the four phases of deploy-key rotation and identify the one that teams most often skip.

  4. Q4. Diagnose why a six-month-old deploy key remains a valid credential even though the team believes it was rotated.

    A team's CI runner was migrated to a new host six months ago. The migration included copying the SSH private key from the old host to the new host, then deleting the key file from the old host. The deploy key list on the repository still shows the key as installed. The team believes the key was 'rotated' because the old host no longer holds it.

Passing score: 75%. Answers are checked in this browser.