Every extra byte in a runtime image is an extra byte of attack surface. An ideal production image contains only the application binary, a few shared libraries, no shell, no package manager — and must be signed so the cluster can trust it.
Multi-stage builds and distroless base images
Reference pattern for a Go application:
FROM golang:1.23 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /out/app ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/app /app USER nonroot:nonroot ENTRYPOINT ["/app"]
Benefits:
- Runtime image <20 MB, no
sh,apt,curl. - Once an attacker reaches RCE, there are no tools to escalate or download a payload.
- OS-level CVE count drops to near zero, reducing scan noise.
For Node.js/Python: use Chainguard Images, distroless/nodejs, distroless/python3. Avoid :latest; always pin by digest @sha256:....
Non-root, read-only filesystem, dropped capabilities
In Kubernetes:
securityContext:
runAsNonRoot: true
runAsUser: 65532
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
This is the baseline of the Pod Security Standards: restricted profile. If the app needs to write files (cache, tmp), mount an emptyDir at that path instead of opening the rootfs.
Scan images with Trivy/Grype
- name: Build
run: docker build -t ghcr.io/org/app:${{ github.sha }} .
name: Scan image uses: aquasecurity/trivy-action@master with: image-ref: ghcr.io/org/app:${{ github.sha }} severity: CRITICAL,HIGH exit-code: 1 ignore-unfixed: true
ignore-unfixed: true avoids breaking builds for CVEs without a fix yet — but they still appear in the report for tracking.
Cosign keyless: signing without managing private keys
Cosign keyless relies on an OIDC identity (GitHub Actions, Google, ...) and a short-lived certificate issued by Fulcio. Workflow:
permissions:
id-token: write # for OIDC
contents: read
packages: write
-
uses: sigstore/cosign-installer@v3
-
name: Sign image env: COSIGN_EXPERIMENTAL: "true" run: cosign sign --yes ghcr.io/org/app@${{ steps.push.outputs.digest }}
name: Attach SBOM as attestation run: | syft ghcr.io/org/app@${{ steps.push.outputs.digest }} -o cyclonedx-json > sbom.json cosign attest --yes --predicate sbom.json --type cyclonedx
ghcr.io/org/app@${{ steps.push.outputs.digest }}
Verification entries are stored permanently on the Rekor transparency log. There is no private key to store or rotate.
Verify before deploy with Kyverno
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: Enforce
rules:
- name: check-cosign-signature
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences: ["ghcr.io/org/*"]
attestors:
- entries:
- keyless:
subject: "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main"
issuer: "https://token.actions.githubusercontent.com"
Pods are admitted only if the image carries a signature from the right repo + workflow + branch. This is a key building block of supply chain security (SLSA L2-L3).
Production image checklist
- Multi-stage build, distroless/Chainguard base, pinned by digest.
- Non-root USER, drop ALL capabilities, readOnlyRootFilesystem.
- No
curl,wget,bashin the runtime stage. - Scanned by Trivy/Grype, build fails on CRITICAL/HIGH with available fix.
- Signed keyless with Cosign + SBOM CycloneDX attached.
- Cluster has Kyverno verifyImages blocking unsigned images.
- Clear HEALTHCHECK + EXPOSE; no unused ports exposed.
Conclusion
Image hardening is one of the highest-ROI investments in DevSecOps: small effort, big reduction in both attack surface and alert fatigue. Once the distroless + non-root + Cosign keyless pattern is internalised, every new service inherits a strong baseline almost for free — and you can confidently turn on admission policies that block non-compliant pods cluster-wide.
