Skip to content

Image Signing

Image signing lets you prove who built an image and whether it has been changed since it left CI. Signing is optional by default on Kupe, but it is a strong practice for production workloads.

If you publish container images from GitHub Actions to GHCR, the easiest path is the public kupecloud/github-workflows repository. Its reusable publish-image.yaml workflow already:

  • builds and pushes your image to ghcr.io/<owner>/<repo>
  • publishes immutable sha-<git-sha> tags
  • publishes semantic version tags
  • signs the pushed image digest with cosign using GitHub OIDC

Reference it from your repository and pin it to a full commit SHA:

name: Publish Image
on:
push:
tags:
- "1.*.*"
permissions:
contents: read
packages: write
id-token: write
jobs:
publish:
uses: kupecloud/github-workflows/.github/workflows/publish-image.yaml@<full-commit-sha>
with:
version: ${{ github.ref_name }}
secrets: inherit

The workflow expects version without a leading v. If your release tags are shaped like v1.2.3, strip the prefix before calling it.

This is the recommended path if you want the simplest supported setup.

Image signing proves:

  • Who built it — the signature is tied to your CI identity (e.g., your GitHub Actions workflow)
  • It hasn’t been tampered with — any modification after signing invalidates the signature
  • When it was built — an immutable timestamp is recorded in a public transparency log

Without signing, you’re trusting that the image in your registry is exactly what your CI built. Signing removes that trust assumption.

Alternative: Sign Your Images Manually in GitHub Actions

Section titled “Alternative: Sign Your Images Manually in GitHub Actions”

If you need a different registry, custom build logic, or full control over tags and build arguments, keep the workflow local. Prefer publishing an immutable Git SHA tag for every image build, and keep tags like latest or release tags only as convenience aliases:

name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # Required for keyless signing
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
push: true
tags: |
ghcr.io/your-org/your-image:${{ github.sha }}
ghcr.io/your-org/your-image:latest
- name: Sign image
run: |
cosign sign --yes \
ghcr.io/your-org/your-image@${{ steps.build.outputs.digest }}

That’s it. The cosign sign --yes command:

  1. Requests a short-lived signing certificate from Sigstore’s CA using your GitHub OIDC token
  2. Signs the image digest with an ephemeral key
  3. Records the signature in a public transparency log
  4. Attaches the signature to your image in the registry
  5. Destroys the ephemeral key — no secrets to manage
  • No keys to manage — signing uses your GitHub OIDC identity, not long-lived secrets
  • No infrastructure — uses free public Sigstore services
  • id-token: write — this permission is required for GitHub to issue the OIDC token
  • Prefer immutable tags — publish a Git SHA tag for every build and use that in deployments when possible
  • Sign the digest, not the tag — tags are mutable, digests are not

You can verify any signed image:

Terminal window
# Install cosign
brew install cosign
# Verify a signed image
cosign verify \
--certificate-identity "https://github.com/your-org/your-repo/.github/workflows/build.yaml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/your-org/your-image:${GIT_SHA}

If verification succeeds, you’ll see the signing certificate details. If the image is unsigned or the signature doesn’t match, the command fails.

If you want to ensure only signed images run in your managed cluster, contact support to discuss a tenant-specific verification policy. A typical rollout:

  • checks your images against the signing identity you use in CI
  • can start in audit mode so you can see what would be blocked
  • can move to enforcement once your team is ready