Sign Docker Images With Cosign: A Homelab Guide
Stop trusting container tags blindly. Sign and verify Docker images with Cosign so your homelab deploys artifacts you can actually identify.
Most homelab Compose files have a trust problem hiding in plain sight:
image: ghcr.io/example/app:latest
That line does not say who built the image. It does not say which commit became the image. It definitely does not say that the bytes you pull tomorrow are the bytes you tested today. It just asks Docker to download whatever currently answers to latest, then gives it a place on your server.
That is normal. It is also a bit too trusting once a container can see your reverse proxy, database credentials, or Docker socket.
Cosign is the small, useful tool for this job. You sign an image after building it, then verify that signature before deployment. Think of it as a wax seal for a container image, except the seal is cryptographic and your Compose stack can care about it.
The short version
Image signing does not scan an image for vulnerabilities. It answers a different question: did this exact image come from a key I trust?
That distinction matters. A signed image can still contain a terrible dependency. An unsigned image can be perfectly harmless. But signing stops a tag moving underneath you without any proof of who moved it.
For a homelab, I think this is worth doing for images you build yourself, images deployed from a private registry, and anything that touches sensitive data. I would not turn a single Raspberry Pi experiment into a compliance project. Start where the blast radius is real.
Why tags are not enough
Tags are labels, not identities. v1.4.2 is better than latest, but a registry maintainer can still retag it. Image digests are immutable identifiers, but they only tell you what you have, not who approved it.
A good deploy flow uses both:
- a digest pins the exact image bytes;
- a Cosign signature proves a trusted key signed those bytes;
- a vulnerability scanner tells you what is inside them.
People often jump straight to scanning and call it supply-chain security. Scanning is necessary, but it will not catch a perfectly clean, malicious replacement image. Different lock, different door.
What we are building
This guide uses key-pair signing because it is easy to understand and works without a public identity provider. The basic flow is:
- build and push an image to a registry;
- create an offline signing key once;
- sign the pushed image by digest;
- verify it from a clean machine or deployment step;
- deploy the verified digest, not a floating tag.
For a personal server, keep the private key off the server that runs your containers. If that server gets popped and it also holds the signing key, the signature becomes a very expensive sticker.
Install Cosign
Use the official Cosign installation instructions for your operating system. Once it is installed, check that the binary is available:
cosign version
Do this on your workstation or in a locked-down CI runner, not necessarily on the production host. The production host only needs the public key and a verification step.
Step 1: Create a signing key pair
Make a dedicated directory on the machine that will sign releases:
mkdir -p ~/container-signing
cd ~/container-signing
cosign generate-key-pair
Cosign creates cosign.key and cosign.pub. It asks for a password to encrypt the private key. Use a password manager, not a shell history entry or a .env file next to the key.
The files have very different jobs:
cosign.keyis private and signs releases;cosign.pubis safe to copy to CI and deployment hosts;- losing the private key means you cannot sign new releases;
- leaking the private key means an attacker can sign a replacement image.
Back up the private key and its password separately. This is one of those boring ten-minute tasks that feels ridiculous until a laptop dies.
Step 2: Build and push a tagged image
Cosign attaches signatures to an image already stored in a registry, so push first. Here is a simple GHCR example:
export IMAGE="ghcr.io/your-github-user/notes-api:1.0.0"
docker build -t "$IMAGE" .
docker push "$IMAGE"
Use a real release tag. 1.0.0, a Git SHA, or a date-based tag all beat latest. The important part is that your deployment notes can name a specific release without guessing what latest meant last Tuesday.
After the push, get the immutable digest:
docker buildx imagetools inspect "$IMAGE" --format '{{json .Manifest.Digest}}'
Set it as a separate variable after checking the output:
export DIGEST="sha256:replace-with-the-digest-you-just-inspected"
export IMAGE_REF="${IMAGE%@*}@$DIGEST"
If your image name does not already contain @, this produces a reference such as ghcr.io/your-github-user/notes-api:1.0.0@sha256:.... The digest is the piece that must not change.
Step 3: Sign the digest
Sign the immutable reference, not a loose tag:
cosign sign --key ~/container-signing/cosign.key "$IMAGE_REF"
Cosign asks for the key password and publishes the signature beside the image in the registry. Registry support and permissions matter here. A private GHCR package, for example, needs credentials allowed to write its signature artifact too.
The first time I explain this, someone inevitably asks why the signature is not copied into the image. Keeping it as a related registry artifact is useful: you can add signatures without rebuilding the image, and the original digest stays the identity being signed.
Step 4: Verify before you deploy
Copy only cosign.pub to the machine that verifies releases. Then run:
cosign verify \
--key /etc/cosign/cosign.pub \
"$IMAGE_REF"
A successful command prints the verified payload. Treat a non-zero exit code as a failed deploy. Do not turn it into a warning that scrolls past in a terminal full of green text.
Here is a small release gate suitable for a deployment script:
#!/usr/bin/env bash
set -euo pipefail
IMAGE_REF="$1"
PUBLIC_KEY="/etc/cosign/cosign.pub"
cosign verify --key "$PUBLIC_KEY" "$IMAGE_REF" >/dev/null
docker pull "$IMAGE_REF"
docker compose up -d
The set -e is doing important work. If verification fails, the script exits before docker pull and before Compose changes anything. That is the behavior you want at 2am, when a rushed rollback is tempting you to skip the inconvenient bit.
Put the digest in Compose
Once verification succeeds, use the digest in your Compose file:
services:
notes-api:
image: ghcr.io/your-github-user/notes-api@sha256:replace-with-your-verified-digest
restart: unless-stopped
environment:
NODE_ENV: production
Yes, the line is ugly. It is supposed to be. A digest makes a deployment explicit, reviewable, and repeatable. Update it through a pull request or a tracked change, verify it, then deploy.
If hand-editing digests sounds tedious, let Renovate or your CI create the update. The rule stays the same: a human or automated policy verifies the signature before the running stack changes.
Keep the signing key away from the server
The obvious shortcut is copying cosign.key to /opt/stacks and signing right before docker compose up. Please do not.
A better setup looks like this:
- CI builds, scans, pushes, and signs from a protected runner;
- the registry stores the image and its signature;
- the homelab server has only
cosign.pub; - deployment verifies the digest with that public key.
When administering that server from a hotel, café, or borrowed network, protect the management connection too. Image signing cannot protect a deployment command typed into a compromised network session.
🚀NordVPN
Secure your server with a reliable VPN.
Affiliate link — we may earn a commission at no extra cost to you.
The gotchas worth knowing
Signing a tag is not enough. The tag can move after you sign it. Sign and verify the digest reference that will be deployed.
A successful signature is not a security audit. Keep scanning images, run as non-root when possible, and do not mount /var/run/docker.sock into random containers. Signing gives provenance, not invincibility.
Key rotation needs a plan. During a rotation, allow both old and new public keys temporarily, re-sign active releases with the new key, then remove trust for the old one. Do not delete the old public key before every running image has a migration path.
Registry cleanup can remove signatures. Test your registry retention rules. If they garbage-collect related signature artifacts, your next verification will fail even though the image still exists.
Where to go from here
Pick one image you build yourself this week. Create a key pair, push a versioned tag, sign its digest, and make a verification command pass before you touch production.
That is enough to change the habit. Your Compose files stop saying “run whatever this label points at” and start saying “run this exact artifact, approved by this key.” For a few minutes of setup, that is a remarkably good trade.
For deeper workflows, read the Cosign documentation. Once the basic key flow feels boring, keyless signing and CI identity policies are the natural next step.
Stay in the loop 📬
Get self-hosting tutorials, tool reviews, and infrastructure tips delivered to your inbox. No spam, unsubscribe anytime.
Join 0 self-hosters. Free forever.