In the previous post we built a full GitOps pipeline: push to Forgejo, build a Hugo image, ship it to k3s via Flux. It worked. But it was naive in a few ways — running nginx as root, no image scanning, no signing, and deploying by mutable tag.
This post covers the fixes. Three independent improvements, each worth doing on its own:
Unprivileged nginx — drop root from the container entirely
Trivy — scan the image for HIGH/CRITICAL CVEs before it ever touches the registry
Cosign — sign the image so the cluster can verify it came from CI
The full updated files are at the bottom. Here is the reasoning behind each change.
1 — Unprivileged nginx
The original Dockerfile used nginx:1.27-alpine. That image runs the master process as root (it needs to bind port 80). The worker processes drop to nginx, but root is still involved at startup.
The fix is nginxinc/nginx-unprivileged:1.27-alpine. This is the official upstream image maintained by the nginx team. It:
Listens on 8080 instead of 80 (no privileged port, no root needed)
Runs everything as the nginx user from the start
Ships with a conf that already expects unprivileged operation
The only catch: you still want to run apk upgrade to patch OS-level packages. Since the unprivileged image starts as nginx, you need to briefly switch to root to run the upgrade, then drop back:
Nothing else in the k8s manifests needs to change — containerPort: 8080 was already there.
2 — Security Headers and nginx Hardening
While touching the nginx config, a few headers worth adding:
server_tokensoff;# don't leak nginx version in error pages / headers
add_headerX-Content-Type-Options"nosniff"always;add_headerX-Frame-Options"DENY"always;add_headerReferrer-Policy"strict-origin-when-cross-origin"always;add_headerX-XSS-Protection"1;mode=block"always;add_headerStrict-Transport-Security"max-age=31536000;includeSubDomains"always;
The always flag ensures headers are sent even on error responses, not just 200s. HSTS only makes sense because Cloudflare terminates TLS in front — the header tells browsers to enforce HTTPS for a year.
3 — Trivy: Scan Before Push
The workflow used to build, push, done. The new order is: build, scan, push only if clean.
Trivy runs against the locally-built image (via digest) before it ever reaches the registry. If it finds any unfixed HIGH or CRITICAL CVEs, the pipeline exits 1 and the image is never pushed.
--ignore-unfixed skips CVEs where no fix exists yet — these are noise you can’t act on.
--exit-code 1 is what actually gates the pipeline. Without it, Trivy is just reporting.
The Trivy DB cache step matters on self-hosted runners where the DB has to be downloaded every run. On Forgejo runners it cuts scan time significantly.
Trivy is installed via the official apt repo rather than curl | bash, which is the right call in a CI environment.
4 — Cosign: Sign the Image
After scanning, the image gets signed with Cosign using a key stored in Forgejo secrets. This gives you a verifiable chain: this exact digest was produced by this CI run and nobody tampered with it between push and deploy.
--registry-referrers-mode=legacy is needed for Forgejo’s container registry, which doesn’t yet support the OCI 1.1 referrers API. Without this flag, Cosign tries to use the newer spec and fails.
The signature is stored as a separate tag in the same registry, referenced by the image digest.
Generating the key pair
If you haven’t done this yet:
cosign generate-key-pair
# outputs cosign.key (private) and cosign.pub (public)
Add to Forgejo secrets:
Secret
Value
COSIGN_PRIVATE_KEY
contents of cosign.key
COSIGN_PASSWORD
the passphrase you set
Keep cosign.pub in your repo or somewhere accessible — you’ll need it to verify.
A digest ref (image: registry/repo@sha256:abc123...) is immutable. A tag ref is not — someone could push a different image to the same tag. Deploying by digest means Kubernetes will always pull exactly the image that was scanned and signed, nothing else.
This also pairs naturally with Cosign verification: the digest in the manifest is the same one the signature is attached to.
None of these are dramatic changes. Together they close the obvious gaps in the original setup: a root-running container, no verification that the image is clean, no way to prove the image wasn’t tampered with after it left CI, and a mutable tag that Kubernetes could silently re-pull to a different image.