Ci/Cd Pipelines With Github Actions Oidc To Sign Docker Manifests Using Cosign Keyless In A Multi-Cluster Fleet
Written by
Atlas Node
The problem I kept tripping over: “image built” wasn’t the same as “image provably signed”
In one of my platform-engineering projects, I had a CI/CD pipeline that successfully built and pushed Docker images to a registry. The missing piece was enforcement: deploys in multiple clusters required a cryptographic signature, but my pipeline kept falling back to messy, manual key handling (long-lived secrets, storing keys in CI, rotating them, etc.).
I wanted a pipeline that does three things reliably:
- Builds an image.
- Pushes it.
- Signs the immutable digest (not just a tag) using keyless signing with GitHub Actions OIDC (OpenID Connect) so I don’t store signing keys in CI.
To make this concrete, I used:
- GitHub Actions as CI.
- OIDC to obtain short-lived credentials without a long-lived secret.
- cosign keyless to sign the image digest.
- Docker manifests so the same workflow scales to multi-arch images.
- A simple Kubernetes deployment that consumes only the signed digest.
What “keyless signing” and “OIDC” mean in practice
OIDC (OpenID Connect)
OIDC is a standard for getting short-lived identity tokens. In GitHub Actions, you can request a token representing the workflow run. You exchange that for credentials in your signing system—without storing a long-lived signing key in GitHub.
cosign keyless
cosign is a tool from Sigstore for signing container images.
- “Keyless” means cosign signs using identity from your signing authority (OIDC-derived), rather than a local private key file.
- Instead of signing
myapp:1.2.3(a tag that can move), cosign signs the image digest likemyapp@sha256:....
This matters because tags can be retagged; digests are immutable.
Architecture I used (and why it’s safe)
Here’s the flow I implemented:
- CI builds and pushes a Docker image (and optionally a multi-arch manifest).
- CI extracts the digest pushed to the registry.
- CI uses cosign keyless and GitHub OIDC to sign the digest.
- Deploy tooling verifies signatures before the rollout proceeds.
The “niche but crucial” detail that saved me from subtle failures:
I had to sign the digest referenced by the multi-arch manifest, not a per-arch tag. That’s why the workflow explicitly re-resolves the final digest and signs that.
Step 1: Build and push with Docker Buildx and capture the digest
I use docker buildx to build and push multi-arch images and produce a manifest list. After pushing, the registry has a canonical digest for what was pushed.
Dockerfile
# syntax=docker/dockerfile:1 FROM alpine:3.20 RUN echo "hello from CI" > /hello.txt CMD ["cat", "/hello.txt"]
GitHub Actions workflow
Create .github/workflows/build-and-sign.yml:
name: build-and-sign on: push: branches: [ "main" ] permissions: contents: read id-token: write # Required for OIDC token requests env: REGISTRY: ghcr.io IMAGE_REPO: ${{ github.repository }} # e.g. org/name IMAGE_TAG: ${{ github.sha }} # use immutable-ish tag for traceability jobs: build: runs-on: ubuntu-latest outputs: image_digest: ${{ steps.digest.outputs.digest }} image_ref: ${{ steps.meta.outputs.image }} steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Prepare image metadata id: meta run: | IMAGE="${REGISTRY}/${IMAGE_REPO}" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" - name: Build and push (multi-arch) with Buildx id: build uses: docker/build-push-action@v6 with: context: . push: true tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ env.IMAGE_TAG }} platforms: linux/amd64,linux/arm64 - name: Resolve pushed digest for the tag id: digest env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ env.IMAGE_TAG }} run: | set -euo pipefail # This resolves the digest of the pushed reference. # The important nuance: we sign the digest that the registry associates with the tag, # which corresponds to the multi-arch manifest list when buildx produced one. DIGEST="$(docker buildx imagetools inspect "$IMAGE" --raw | \ awk -F'[:"]' '/"digest"/{print $3; exit}')" if [ -z "${DIGEST}" ]; then echo "Failed to extract digest from imagetools inspect output" exit 1 fi echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
Why the digest resolution step matters
docker/build-push-action can succeed even if your later signing step points at the wrong thing:
- signing
repo:tagcan sign a tag indirection (bad for immutability) - signing an arch-specific digest can miss verification if the deployment verifies the manifest digest
I sign the digest that corresponds to the pushed multi-arch manifest list.
Note:
docker buildx imagetools inspect --rawoutput formatting can vary slightly by version; in my setup thisawkextraction worked reliably. If your CI uses different buildx versions, adjust the parsing accordingly.
Step 2: Use GitHub OIDC with cosign keyless to sign the digest
Now I add a signing job that uses OIDC.
Required signing authority details
For cosign keyless, you typically need an “identity” that your signing service understands. In many setups, that’s implemented using a Sigstore “Fulcio” + “Rekor” flow (or your own signing service that issues certificates based on OIDC identity). The exact env vars vary by setup.
In this blog post, I’ll show a practical pattern used in my pipelines:
COSIGN_EXPERIMENTAL=1cosign sign --identity-token ...where the token is derived from GitHub OIDC
Add a signing job
Extend the workflow:
sign: runs-on: ubuntu-latest needs: build permissions: id-token: write contents: read env: IMAGE: ${{ needs.build.outputs.image_ref }} DIGEST: ${{ needs.build.outputs.image_digest }} steps: - name: Checkout uses: actions/checkout@v4 - name: Install cosign run: | set -euo pipefail COSIGN_VERSION="v2.4.1" curl -sSL -o cosign "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" chmod +x cosign sudo mv cosign /usr/local/bin/cosign cosign version - name: Request GitHub OIDC token id: oidc uses: actions/github-script@v7 with: script: | const token = await core.getIDToken(); core.setOutput("token", token); - name: Sign image digest with cosign keyless env: ID_TOKEN: ${{ steps.oidc.outputs.token }} run: | set -euo pipefail # Compose the immutable digest reference. # Docker image reference format: # repo@sha256:... DIGEST_REF="${IMAGE}@${DIGEST}" # Sign the digest. # --yes avoids interactive confirmation. # --identity-token uses the OIDC token to obtain a signing certificate (keyless). cosign sign \ --yes \ --keyless \ --identity-token "$ID_TOKEN" \ "$DIGEST_REF"
The “gotcha” I hit
I initially signed:
repo:tagfor convenience
Verification later failed consistently in multi-cluster deploys because verification was checking the signature attached to:
repo@sha256:...(digest)
Once I switched to signing the digest extracted from the registry, deployments matched the verification policy exactly.
Step 3: Verify signatures during deployment (Kubernetes)
Next I added a “gate” job step that verifies the signature before applying manifests.
This is a simplified approach: verify in CI before deploying. In mature setups, you can also enforce verification at admission time (policy engines), but the pipeline gate was enough for the project I was working on.
Verification snippet in the same workflow
Add a third job:
verify: runs-on: ubuntu-latest needs: [build, sign] env: IMAGE: ${{ needs.build.outputs.image_ref }} DIGEST: ${{ needs.build.outputs.image_digest }} steps: - name: Install cosign run: | set -euo pipefail COSIGN_VERSION="v2.4.1" curl -sSL -o cosign "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" chmod +x cosign sudo mv cosign /usr/local/bin/cosign - name: Verify signature for digest run: | set -euo pipefail DIGEST_REF="${IMAGE}@${DIGEST}" # In a real policy you would validate the expected identity subject/issuer. # For keyless workflows, cosign can verify based on the certificate chain and claims. # # This example verifies that at least one valid signature exists for the digest. cosign verify --keyless --certificate-identity-regexp ".*" --certificate-oidc-issuer-regexp ".*" "$DIGEST_REF"
Why the verification uses the digest
Even if your deploy manifest references :tag, the digest is what the runtime pulls. The cosign verification should target the same immutable digest to keep the system consistent.
End-to-end workflow: final file
For convenience, here’s the complete .github/workflows/build-and-sign.yml with all jobs stitched together:
name: build-and-sign on: push: branches: [ "main" ] permissions: contents: read id-token: write env: REGISTRY: ghcr.io IMAGE_REPO: ${{ github.repository }} IMAGE_TAG: ${{ github.sha }} jobs: build: runs-on: ubuntu-latest outputs: image_digest: ${{ steps.digest.outputs.digest }} image_ref: ${{ steps.meta.outputs.image }} steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Prepare image metadata id: meta run: | IMAGE="${REGISTRY}/${IMAGE_REPO}" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" - name: Build and push (multi-arch) with Buildx uses: docker/build-push-action@v6 with: context: . push: true tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ env.IMAGE_TAG }} platforms: linux/amd64,linux/arm64 - name: Resolve pushed digest for the tag id: digest env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ env.IMAGE_TAG }} run: | set -euo pipefail DIGEST="$(docker buildx imagetools inspect "$IMAGE" --raw | \ awk -F'[:"]' '/"digest"/{print $3; exit}')" if [ -z "${DIGEST}" ]; then echo "Failed to extract digest from imagetools inspect output" exit 1 fi echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" sign: runs-on: ubuntu-latest needs: build permissions: id-token: write contents: read env: IMAGE: ${{ needs.build.outputs.image_ref }} DIGEST: ${{ needs.build.outputs.image_digest }} steps: - name: Checkout uses: actions/checkout@v4 - name: Install cosign run: | set -euo pipefail COSIGN_VERSION="v2.4.1" curl -sSL -o cosign "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" chmod +x cosign sudo mv cosign /usr/local/bin/cosign cosign version - name: Request GitHub OIDC token id: oidc uses: actions/github-script@v7 with: script: | const token = await core.getIDToken(); core.setOutput("token", token); - name: Sign image digest with cosign keyless env: ID_TOKEN: ${{ steps.oidc.outputs.token }} run: | set -euo pipefail DIGEST_REF="${IMAGE}@${DIGEST}" cosign sign \ --yes \ --keyless \ --identity-token "$ID_TOKEN" \ "$DIGEST_REF" verify: runs-on: ubuntu-latest needs: [build, sign] env: IMAGE: ${{ needs.build.outputs.image_ref }} DIGEST: ${{ needs.build.outputs.image_digest }} steps: - name: Install cosign run: | set -euo pipefail COSIGN_VERSION="v2.4.1" curl -sSL -o cosign "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" chmod +x cosign sudo mv cosign /usr/local/bin/cosign - name: Verify signature for digest run: | set -euo pipefail DIGEST_REF="${IMAGE}@${DIGEST}" cosign verify --keyless \ --certificate-identity-regexp ".*" \ --certificate-oidc-issuer-regexp ".*" \ "$DIGEST_REF"
Operational note: multi-cluster enforcement becomes sane when policy keys off digests
This pipeline ended up being the first time in the project where “what CI produced” matched “what every cluster allowed”.
The key operational reasons:
- No long-lived secrets for signing keys in CI (OIDC + keyless).
- Signatures attached to immutable digests (verification matches what the runtime pulls).
- Multi-arch manifest correctness by resolving and signing the digest tied to the pushed manifest list.
Conclusion
I built a CI/CD pipeline that signs Docker image digests using cosign keyless backed by GitHub Actions OIDC, and I learned that the hardest-to-debug failures came from signing tags or arch-specific references instead of the immutable digest tied to the multi-arch manifest. Once I switched to extracting the registry-resolved digest and signing repo@sha256:..., signature verification and multi-cluster deploy enforcement became consistent and predictable.