𝔩𝔢𝔩𝕠𝔭𝔢𝔷
Theme

Homelab

Signed Release Deploys

Securing GitOps: Deploy Only Signed Releases with GPG and Flux

Overview

This article gates deployments behind signed release tags. Flux stops tracking the branch head and deploys only the newest git tag that carries a valid GPG signature. Code flows freely to main, including commits from AI coding agents, but nothing reaches the cluster until you cut a signed release. A compromised GitHub account can push commits; it can't sign a release, so it can't change cluster state.

Tip

Having trouble? See v1.14.0 for what your setup should look like after completing this article.

Before You Begin

Prerequisites

What We're Setting Up

ComponentPurpose
Public-key SecretYour GPG public key, readable in git (the trust anchor)
GitRepositoryTrack the highest signed release tag instead of main
First releaseSigned v1.14.0 tag, the new unit of deployment

Why Signed Releases

Flux pulls from GitHub and deploys whatever it finds on main. The current trust model:

GitHub account → push to main → Flux deploys → cluster state changes

If your GitHub account is compromised, the attacker has full control of the cluster. Signed releases add a second factor:

GitHub account → push to main → nothing deploys
GitHub account + GPG signing key → git tag -s a release → Flux verifies → deploys

Deployment is decoupled from commits. The signing key lives in your vault, not on GitHub. Flux picks the highest-versioned tag, then verifies its signature: an unsigned or tampered tag fails and the cluster holds on the last good release. So the check sits on the rare, deliberate act of shipping, not on everyday commits, which never need to sign.

Configure Flux Verification

Secret: GPG Public Key (unencrypted in git, on purpose)

The vault's public doc is the source: every device's Publish step (previous article) keeps it current, so the block carries every signing subkey.

flux/config/flux-system/gpg-public-keys.yaml:

---
# GPG public keys Flux uses to verify release-tag signatures.
# Public material — deliberately NOT sops-encrypted: verification needs no
# secrets, and keeping the key readable in git makes the trust anchor
# auditable. The secret halves live in the vault (primary) and on each
# device (its signing subkey); nothing secret appears here.
apiVersion: v1
kind: Secret
metadata:
  name: gpg-public-keys
  namespace: flux-system
stringData:
  identity.asc: |
    -----BEGIN PGP PUBLIC KEY BLOCK-----
    <output of: op document get 'gpg | identity public' --vault identity>
    -----END PGP PUBLIC KEY BLOCK-----

When a new device joins the identity, refresh this Secret from the vault too.

GitRepository: Track Signed Tags

ref.semver selects the highest tag; verify.mode: Tag requires its signature1. gotk-sync.yaml is bootstrap-generated (DO NOT EDIT), so patch it via kustomize.

flux/config/flux-system/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- gotk-components.yaml
- gotk-sync.yaml
- gpg-public-keys.yaml # ADD
patches: # ADD
  # Deploy from signed release tags, not branch HEAD: commits (agent or
  # human) flow freely to main and never deploy; only a deliberate,
  # presence-gated `git tag -s` changes the cluster. A compromised GitHub
  # account can push code but cannot cut a signed release.
  # New tags sort above the old unsigned article tags, so those are never
  # candidates once the first release exists (cut it before this config
  # reconciles). The JSON patch replaces ref wholesale (branch and semver
  # are mutually exclusive); gotk-sync.yaml stays bootstrap-owned.
  - target:
      kind: GitRepository
      name: flux-system
    patch: |
      - op: replace
        path: /spec/ref
        value:
          semver: "*"
      - op: add
        path: /spec/verify
        value:
          mode: Tag
          secretRef:
            name: gpg-public-keys

One naming rule: only releases get semver-parseable names. Deploy tags are full vX.Y.Z; everything else uses a word-shaped name (test-sig, milestone-foo) the selector ignores. A prerelease suffix (v2.2.0-rc.1) is also excluded from selection, giving a free staging lane.

Git: Commit the Verification Config

Stage the public key and the patch as the first chunk of work:

cd ~/homelab
git add flux/config/flux-system/gpg-public-keys.yaml \
  flux/config/flux-system/kustomization.yaml
git commit -m "feat(flux): deploy from signed release tags"

Enforce the Naming Rule at Push Time

Give the rules teeth where tags leave the machine. Git has no tag-creation hook, but pre-push sees every ref. The ancestry check exists because Flux can't do it - tags are repo-global refs and ref.semver has no branch scope.

.githooks/pre-push:

#!/bin/sh
# Enforce the release-tag rules before anything reaches a remote:
#   - names that parse as semver are RESERVED for releases: must be full
#     vX.Y.Z (optionally -prerelease), annotated, GPG-signed, and pointing
#     at a commit on main (tags are repo-global refs and Flux's selector
#     has no branch scope, so releases-ship-main is enforced here)
#   - loose semver-ish names (v3, v2.0, 2026.07) are rejected outright, since
#     Flux's parser would coerce them into release candidates
#   - word-shaped names (test-sig, milestone-foo) pass untouched
# The signature check here is presence-only; Flux does the cryptographic
# verification. Escape hatch for bulk historical pushes: git push --no-verify.

strict='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$'
loose='^v?[0-9]+(\.[0-9]+){0,2}(-.*)?$'
zero='0000000000000000000000000000000000000000'

while read -r local_ref local_sha remote_ref remote_sha; do
    case "$remote_ref" in
        refs/tags/*) ;;
        *) continue ;;
    esac
    tag=${remote_ref#refs/tags/}
    [ "$local_sha" = "$zero" ] && continue # tag deletion

    if printf '%s' "$tag" | grep -Eq "$strict"; then
        if [ "$(git cat-file -t "$local_sha")" != "tag" ]; then
            echo "pre-push: '$tag' is release-shaped but lightweight; sign it (git tag -s)" >&2
            exit 1
        fi
        if ! git cat-file tag "$local_sha" | grep -q 'BEGIN PGP SIGNATURE'; then
            echo "pre-push: '$tag' is release-shaped but unsigned; sign it (git tag -s)" >&2
            exit 1
        fi
        if ! git merge-base --is-ancestor "$local_sha^{commit}" refs/heads/main; then
            echo "pre-push: '$tag' points at a commit not on main; releases ship main" >&2
            exit 1
        fi
    elif printf '%s' "$tag" | grep -Eq "$loose"; then
        echo "pre-push: '$tag' parses as semver but is not a full vX.Y.Z release." >&2
        echo "  Reserve semver names for releases; use a word-shaped name instead." >&2
        exit 1
    fi
done
exit 0

Activate it alongside the existing hooks (the repo tracks the script; each clone symlinks it):

chmod +x .githooks/pre-push
ln -s ../../.githooks/pre-push .git/hooks/pre-push

Git: Commit the Hook

git add .githooks/pre-push
git commit -m "feat(githooks): enforce release-tag rules at push time"

Deploy: Cut the First Signed Release

Push both commits, then tag at that head. Order matters: the tag must exist the moment Flux switches to tag-tracking, or it has nothing to resolve. Pushing the tag is also the hook's first live test.

git tag -s tags whatever commit you're on - here, the main head you just pushed. Tag from anywhere else and the hook's ancestry check refuses the push.

git push

git tag -s v1.14.0 -m "signed-release deploy gate"   # annotation doubles as release notes
git tag -v v1.14.0                                     # Good signature, before pushing
git push origin v1.14.0

Optionally surface it as a GitHub Release (gh release create v1.14.0 --verify-tag --notes-from-tag); that is presentation only. The signed tag is the deploy artifact, portable to any git remote.

Verify

Verify: Flux Deploys the Signed Release

flux reconcile source git flux-system
kubectl get gitrepository flux-system -n flux-system \
  -o jsonpath='{.status.conditions[?(@.type=="SourceVerified")].message}'

Expected: the reconcile reports fetched revision v1.14.0@sha1:..., and the condition reads:

verified signature of
    - tag 'v1.14.0@<tag-object-sha>' with key '<primary-key-id>'

The key ID is the identity primary's, even though a device subkey made the signature - verification resolves through the cert the subkey is bound to.

Verify: Unsigned Pushes Don't Deploy

Confirm the gate holds from both directions: an unsigned commit on main, and an unsigned release-shaped tag that outranks the good one. The --no-verify matters: it simulates a push that skipped the hook (or came from another machine), which is exactly the case Flux exists to catch.

git commit --allow-empty -m "test: unsigned commit"
git push

git tag v1.14.1                          # higher than v1.14.0, but unsigned
git push --no-verify origin v1.14.1      # --no-verify bypasses the pre-push hook
flux reconcile source git flux-system    # hangs waiting for Ready - Ctrl-C it

The reconcile never completes: it waits for a Ready condition the source can't reach. That wait is the gate working. Read the verdict:

flux get sources git flux-system

Expected:

NAME           REVISION                  SUSPENDED    READY    MESSAGE
flux-system    v1.14.0@sha1:<commit>     False        False    cannot verify signature of tag 'v1.14.1' since it is not signed

The commit changed nothing because main isn't tracked. The v1.14.1 tag is selected - it's the highest - but it carries no signature, so READY goes False while REVISION holds the last verified release. Clean up and reconcile back to green:

git push origin :v1.14.1 && git tag -d v1.14.1
git reset --hard HEAD~1 && git push --force-with-lease

flux reconcile source git flux-system

Expected: fetched revision v1.14.0@sha1:... - the source recovers the moment the imposter tag is gone.

Optional: GitHub Verified Badge

To get the "Verified" badge on your commits and tags2, add the full public cert under GitHub → Settings → SSH and GPG keys → New GPG key - one key, the same vault doc the Flux Secret uses. Public material is clipboard-safe:

op document get 'gpg | identity public' --vault identity | pbcopy

GitHub reads the subkeys out of the block and attributes any device's signature to the identity. The badge's popover names the signing subkey - the device that cut the tag - where Flux's condition names the primary; same identity, read from different ends of the cert. When a device joins later, refresh the key here too: delete and re-add - verification is evaluated against the account's current keys, so the same primary re-verifies past signatures.

Skip vigilant mode, which the popover advertises: it badges every unsigned commit "Unverified", and in this model commits are unsigned on purpose - agents included. This is provenance only; the deploy gate is Flux's tag verification, not GitHub.

Next Steps

With deployments gated behind signed release tags, the cluster's supply chain is hardened end to end - and the Security Hardening series is complete. The checklist gaps left open on purpose (API access scoping, cluster backups, monitoring) belong to a future series.

Resources

Footnotes

  1. Flux, "Git Repository Verification," fluxcd.io. Accessed: Apr. 13, 2026. [Online]. Available: https://fluxcd.io/flux/components/source/gitrepositories/#verification

  2. GitHub, "Managing commit signature verification," docs.github.com. Accessed: Apr. 13, 2026. [Online]. Available: https://docs.github.com/en/authentication/managing-commit-signature-verification

Previous
GPG Identity