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.
Having trouble? See v1.14.0 for what your setup should look like after completing this article.
Before You Begin
Prerequisites
- Flux CD Kubernetes GitOps completed (Flux deploying from GitHub)
- GPG Identity completed: vaulted identity key, a signing subkey on this device,
git tag -sverified working
What We're Setting Up
| Component | Purpose |
|---|---|
| Public-key Secret | Your GPG public key, readable in git (the trust anchor) |
| GitRepository | Track the highest signed release tag instead of main |
| First release | Signed 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
Flux, "Git Repository Verification," fluxcd.io. Accessed: Apr. 13, 2026. [Online]. Available: https://fluxcd.io/flux/components/source/gitrepositories/#verification ↩
GitHub, "Managing commit signature verification," docs.github.com. Accessed: Apr. 13, 2026. [Online]. Available: https://docs.github.com/en/authentication/managing-commit-signature-verification ↩