Signing commits inside ephemeral containers Jump to heading

Containers break signing in two predictable ways. Either the container has no key and no agent, so every commit fails with “gpg failed to sign the data”, or someone fixes that by copying a key into the image, where it is now readable by anyone who can pull the image, forever, in every layer cache. Neither is acceptable. The right answer depends on who is signing: a developer working in a dev container should sign with their own key held on the host, and an automated job should receive a key at runtime that dies with the container. This page covers both, within signed commits in CI pipelines.

When to use this approach Jump to heading

  • Developers use dev containers or container-based development environments and commit from inside them.
  • CI jobs run in custom images and need to create signed commits or tags.
  • You found, or suspect, a private key inside a container image or its build context.
  • The key holder is either a person on the host or a CI secret — never the image itself.

Step 1 — Check that no image contains a key Jump to heading

Before fixing signing, make sure nobody already “fixed” it the wrong way. Search the images you build for private key material, including in earlier layers that a later RUN rm did not really remove.

# Save the image and search every layer, not just the final filesystem
docker save my/dev-image:latest -o image.tar
mkdir layers && tar -xf image.tar -C layers
find layers -name '*.tar' -exec sh -c 'tar -tf "$1" | grep -E "(\.ssh/id_|\.gnupg/private-keys)" | sed "s|^|$1: |"' _ {} \;

Any hit means the key is published to everyone who can pull the image. Rotate it, then rebuild the image without it, following responding to a leaked credential.

Three ways to get a key into a containerBaking a key into the image publishes it to everyone who can pull the image, in every layer. Forwarding the host agent keeps the key on the host and lets the container request signatures. Injecting a key at runtime from a secret store gives automation a key that disappears with the container.Where the key livesVerdictCOPY into imageevery image layerneverforward host agenthost onlydevelopersinject at runtimecontainer memoryCI jobsif the key is in an image layer, assume everyone with pull access has it

Step 2 — Dev containers: forward the host’s SSH agent Jump to heading

For a developer, the key belongs on their machine. Mount the host agent’s socket into the container and point Git at the developer’s public key. Signing requests travel out through the socket; the private key never enters the container.

// .devcontainer/devcontainer.json
{
  "mounts": [
    "source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent,type=bind"
  ],
  "remoteEnv": { "SSH_AUTH_SOCK": "/ssh-agent" },
  "postCreateCommand": "git config --global gpg.format ssh && git config --global commit.gpgSign true"
}
# Inside the container: use the forwarded key by its public half
git config --global user.signingKey "key::$(ssh-add -L | grep signing | head -1)"
git commit --allow-empty -m "signed from container" && git log -1 --format='%G?'

Many editors forward the agent automatically when they start a dev container, in which case only the Git configuration is needed. Check with ssh-add -L inside the container before adding a mount.

A dev container signing through the host agentGit inside the container calls ssh-keygen, which sends the signing request over the forwarded socket to the agent on the host. The host agent signs with the key it holds and returns the signature, so the container never sees the private key.git (container)ssh-keygensocket mounthost agent-Y signrequestforwardedsignaturesignaturecommit signedrebuild or delete the container and nothing sensitive is lost

Step 3 — GPG in a dev container: forward the agent’s extra socket Jump to heading

GPG has a dedicated “extra” socket meant for exactly this: it allows signing but restricts some administrative operations. Forward that socket rather than the main one.

# On the host: find the extra socket
gpgconf --list-dirs agent-extra-socket
{
  "mounts": [
    "source=${localEnv:HOME}/.gnupg/S.gpg-agent.extra,target=/home/vscode/.gnupg/S.gpg-agent,type=bind"
  ]
}

The container needs the public key imported and the same user.signingKey subkey ID as the host. No secret key material is copied.

Step 4 — CI containers: inject a key at runtime Jump to heading

Automation has no host agent to forward. Pass the key in as a secret environment variable or mounted file at run time and load it into an agent inside the container, exactly as on a plain runner.

# GitHub Actions job running in a container image
jobs:
  bump:
    runs-on: ubuntu-latest
    container: { image: ghcr.io/example/release-tools:1.8 }
    environment: release-signing
    steps:
      - uses: actions/checkout@v4
      - run: |
          eval "$(ssh-agent -s)"
          printf '%s\n' "$SIGNING_KEY" | ssh-add -
          git config user.signingKey "key::$(ssh-add -L | head -1)"
          git config gpg.format ssh
          ./ci/bump-and-commit.sh
        env: { SIGNING_KEY: "${{ secrets.RELEASE_BOT_KEY }}" }

The image needs ssh-keygen installed (openssh-client on Debian-based images, openssh-keygen on Alpine). Its absence produces a “cannot run ssh-keygen” error that Git reports, unhelpfully, as a signing failure.

# Verification inside the image
command -v ssh-keygen && ssh-keygen -Y sign 2>&1 | head -1

Step 5 — Never let the build context carry a key Jump to heading

Docker sends the build context to the daemon, and COPY . . copies all of it. A key sitting in the project directory ends up in the image even if no instruction mentions it. Exclude key material in .dockerignore, and use build secrets for anything a build step genuinely needs.

# .dockerignore
**/.ssh
**/.gnupg
**/*.pem
**/id_*
# A build step that needs a key uses a secret mount: never stored in a layer
RUN --mount=type=ssh git clone [email protected]:org/private-dep.git
Keeping keys out of every layer of a container workflowThe build context excludes key directories, build steps use secret or SSH mounts instead of copies, the image ships without keys, and keys arrive only at runtime through a forwarded agent or an injected secret.where keys are stopped at each stageBuild context.dockerignore excludes keysBuild steps--mount=type=ssh, not COPYPublished imageno key material in any layerRuntimeforwarded agent or injected secretthe image is shared widely and lives forever; runtime state is private and short-lived

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is agent forwarding into a container risky? Jump to heading

Anything running in the container can ask the agent to sign while the socket is mounted. That is the same exposure as any process on the host, so it adds little risk for a developer’s own container. Do not forward a personal agent into containers running untrusted code.

Can I use keyless signing in containers instead? Jump to heading

Yes. Keyless signing needs network access to the identity provider and signing service, but no key at all, which suits CI containers well. See keyless commit signing with Sigstore gitsign.

Why does signing work in the container shell but fail in a post-create script? Jump to heading

Post-create scripts often run before the editor has set up agent forwarding, so SSH_AUTH_SOCK is empty at that moment. Move signing-dependent steps to a later lifecycle hook, or make them tolerate a missing agent.