Golden Path Security: Building Security In from Day Zero with Paved Road Templates
What “Golden Path” Actually Means for Security
Platform engineering teams talk about golden paths as productivity tools: one scaffold command, and a developer has a runnable service with CI/CD, observability, and a Helm chart. That framing leaves security as a separate concern. The security perspective is different: the golden path is the only realistic point at which you can make secure defaults cheaper than insecure ones.
Every hardening control you add after a service ships has a cost: developer attention, regression risk, migration effort, exception handling. The same control embedded in a template has near-zero marginal cost per service. A Semgrep scan configured in the scaffold-generated CI workflow costs nothing to the 200th team that uses the template.
The goal is not to prevent developers from doing insecure things — it is to make the secure thing the path of least resistance. If the template generates a Helm chart with securityContext.runAsNonRoot: true and a minimal distroless base image, developers running the golden path get those controls by default. Deviating from them requires deliberate effort and triggers policy enforcement.
This also defines where the responsibility boundary sits. The platform team owns the golden path security controls. Individual teams own the application-layer decisions (business logic, data validation, authentication design). The division is clean and auditable.
For broader context on how this fits into an internal developer platform security strategy, particularly around RBAC, namespace isolation, and secrets management within the platform itself, read that companion article first.
Designing Secure Service Templates in Backstage
Backstage’s scaffolder is the standard mechanism for implementing golden paths. A scaffolder template is a YAML-defined wizard that collects parameters from the developer and uses them to render files from a template directory, register the service in the catalog, and trigger optional actions (create repo, provision namespace, configure CI secrets).
Security controls belong in the template rendering layer, not in documentation.
A minimal secure Backstage scaffolder template for a Kubernetes service looks like this:
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: secure-go-service
title: Secure Go Microservice
description: Go service with security controls pre-configured
tags:
- go
- kubernetes
- golden-path
spec:
owner: platform-team
type: service
parameters:
- title: Service Details
required:
- name
- system
properties:
name:
title: Service Name
type: string
pattern: '^[a-z][a-z0-9-]{2,62}$'
description: Lowercase, alphanumeric, hyphens only
system:
title: Owning System
type: string
ui:field: EntityPicker
ui:options:
catalogFilter:
kind: System
imageRegistry:
title: Container Registry
type: string
default: registry.internal.example.com
enum:
- registry.internal.example.com
description: Only approved internal registries
steps:
- id: fetch-template
name: Fetch Template
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
system: ${{ parameters.system }}
imageRegistry: ${{ parameters.imageRegistry }}
sbomEnabled: true
slsaEnabled: true
networkPolicyEnabled: true
- id: publish
name: Publish to GitHub
action: publish:github
input:
repoUrl: github.com?repo=${{ parameters.name }}&owner=my-org
defaultBranch: main
repoVisibility: internal
requireCodeOwner: true
dismissStaleReviews: true
requiredApprovingReviewCount: 1
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
The imageRegistry field is constrained to an enum with a single approved value. A developer cannot scaffold a service that references Docker Hub as the base image registry. The sbomEnabled, slsaEnabled, and networkPolicyEnabled values are hardcoded to true in the step input — they are not parameters the developer can toggle.
The skeleton directory contains the actual rendered files. Key security-relevant files in the skeleton:
.github/workflows/ci.yml— the CI workflow with security steps pre-wiredDockerfile— distroless base, non-root user, pinned digesthelm/templates/deployment.yaml— with securityContext blockshelm/templates/networkpolicy.yaml— default-deny ingress/egress with minimal allow rulescatalog-info.yaml— Backstage catalog entry with security metadata
Secure Base Images: Distroless, Pinned Digests, Non-Root
The skeleton Dockerfile must enforce three things: a minimal base image, a pinned digest (not a floating tag), and a non-root runtime user.
FROM golang:1.22.3-alpine3.19@sha256:cdc86d9f363e8786845bea2f1b49b3b8e0c01f7f87fc73e7a5d80c49e9a3de3f AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot@sha256:6706c73aae2afaa8201d63cc3dda48753c09bcd6c300762251065c0f7e602b2e
COPY --from=builder /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Using @sha256: digest pins mean a dependency update to the base image requires an explicit digest change, which creates a diff in version control, which can be reviewed and scanned. Floating tags (golang:1.22 or latest) allow base image content to change silently between builds.
Distroless images contain only the application and its runtime dependencies — no shell, no package manager, no coreutils. The attack surface for a compromised container is substantially smaller: an attacker who achieves code execution has no curl, no bash, no wget to stage follow-on tooling.
If a team’s service genuinely requires a shell for debugging, the golden path should offer a debug variant that uses a Red Hat UBI minimal or Alpine image, with the distroless variant as the default. The Backstage template can present this as a parameter, but the default must be the more restrictive option.
The digest pinning process should be automated. A Renovate or Dependabot configuration in the skeleton keeps digests current and generates PRs with changelogs:
{
"extends": ["config:base"],
"docker": {
"pinDigests": true
},
"packageRules": [
{
"matchDatasources": ["docker"],
"allowedVersions": "/^(golang|gcr.io\\/distroless)/",
"registryUrls": ["registry.internal.example.com"]
}
]
}
Mandatory Security Controls in Golden Path CI
The skeleton CI workflow pre-configures four security steps that run on every pull request and main branch push. These are not optional jobs that teams enable when they feel like it — they are the CI workflow, and disabling them requires modifying a file that platform teams own via CODEOWNERS.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
packages: write
id-token: write
jobs:
sast:
name: SAST (Semgrep)
runs-on: ubuntu-latest
container:
image: registry.internal.example.com/semgrep:1.72.0@sha256:abc123...
steps:
- uses: actions/checkout@v4
- name: Run Semgrep
run: semgrep scan --config=auto --config=p/security-audit --sarif -o semgrep.sarif
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
build-and-scan:
name: Build, Scan, SBOM, Provenance
runs-on: ubuntu-latest
needs: sast
steps:
- uses: actions/checkout@v4
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: ${{ env.IMAGE_REF }}
load: true
- name: Scan image (Trivy)
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_REF }}
format: sarif
output: trivy.sarif
exit-code: '1'
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Generate SBOM (Syft)
uses: anchore/sbom-action@v0
with:
image: ${{ env.IMAGE_REF }}
format: spdx-json
output-file: sbom.spdx.json
- name: Attest SBOM
uses: actions/attest-sbom@v1
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.spdx.json
push-to-registry: true
- name: Generate SLSA provenance
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
with:
image: ${{ env.IMAGE_NAME }}
digest: ${{ steps.build.outputs.digest }}
registry-username: ${{ secrets.REGISTRY_USER }}
secrets:
registry-password: ${{ secrets.REGISTRY_PASSWORD }}
Semgrep runs in a pre-approved internal image, not the public Docker Hub image. SBOM generation uses Syft via the anchore/sbom-action and attaches the result to the image in the registry as a signed attestation. SLSA provenance at level 3 is generated by the slsa-github-generator reusable workflow, which runs in an ephemeral, isolated environment and produces a provenance document that cannot be forged by code running in the main build job.
The SBOM generation step connects to the practices described in the SBOM generation and consumption article. The SLSA provenance step is covered in depth in SLSA build provenance.
NetworkPolicy and PodSecurityContext by Default in Helm Templates
The skeleton Helm chart generates secure Kubernetes manifests. Security contexts and network policies appear in every chart by default, not as optional values.
helm/templates/deployment.yaml skeleton fragment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "app.fullname" . }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "app.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "app.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "app.serviceAccountName" . }}
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}@{{ .Values.image.digest }}"
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
ports:
- name: http
containerPort: {{ .Values.service.port }}
livenessProbe:
httpGet:
path: /healthz
port: http
readinessProbe:
httpGet:
path: /readyz
port: http
resources:
limits:
cpu: {{ .Values.resources.limits.cpu }}
memory: {{ .Values.resources.limits.memory }}
requests:
cpu: {{ .Values.resources.requests.cpu }}
memory: {{ .Values.resources.requests.memory }}
helm/templates/networkpolicy.yaml:
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "app.fullname" . }}
spec:
podSelector:
matchLabels:
{{- include "app.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
- podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
ports:
- protocol: TCP
port: {{ .Values.service.port }}
{{- range .Values.networkPolicy.additionalIngress }}
- {{ toYaml . | nindent 6 }}
{{- end }}
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
{{- range .Values.networkPolicy.additionalEgress }}
- {{ toYaml . | nindent 6 }}
{{- end }}
{{- end }}
The default values.yaml sets networkPolicy.enabled: true. Ingress is limited to the ingress controller namespace. Egress allows only DNS resolution by default. Teams that need to reach a database or downstream service add entries to networkPolicy.additionalEgress. This forces explicit, reviewable declarations of all allowed traffic rather than silent allow-all defaults.
The image.digest field in values.yaml is required and has no default. A Helm install without a digest value fails. This enforces the same digest pinning at deployment time that the Dockerfile enforces at build time.
Enforcing Golden Path Adoption: Admission Control
Templates solve the creation problem. They do not solve the drift problem: teams that start on the golden path but later modify their manifests to disable security controls, or teams that deploy without using the template at all.
Admission control is the enforcement layer. Kyverno policies running as ValidatingWebhookConfiguration resources reject manifests that violate the security baseline. These policies apply to all workloads in application namespaces, regardless of how the manifest was produced.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-non-root
annotations:
policies.kyverno.io/title: Require Non-Root Containers
policies.kyverno.io/category: Golden Path Enforcement
policies.kyverno.io/severity: high
spec:
validationFailureAction: Enforce
background: true
rules:
- name: check-runAsNonRoot
match:
any:
- resources:
kinds:
- Pod
namespaceSelector:
matchLabels:
environment: production
validate:
message: "Pods must set securityContext.runAsNonRoot: true at pod or container level."
anyPattern:
- spec:
securityContext:
runAsNonRoot: true
- spec:
containers:
- securityContext:
runAsNonRoot: true
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-approved-base-image
annotations:
policies.kyverno.io/title: Require Approved Base Image Registry
policies.kyverno.io/category: Golden Path Enforcement
policies.kyverno.io/severity: critical
spec:
validationFailureAction: Enforce
background: true
rules:
- name: check-registry
match:
any:
- resources:
kinds:
- Pod
namespaceSelector:
matchLabels:
environment: production
validate:
message: "Images must be pulled from registry.internal.example.com"
foreach:
- list: "request.object.spec.containers"
deny:
conditions:
any:
- key: "{{ element.image }}"
operator: NotIn
value:
- "registry.internal.example.com/*"
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-network-policy
annotations:
policies.kyverno.io/title: Require NetworkPolicy Coverage
policies.kyverno.io/category: Golden Path Enforcement
policies.kyverno.io/severity: medium
spec:
validationFailureAction: Enforce
background: false
rules:
- name: check-networkpolicy-exists
match:
any:
- resources:
kinds:
- Namespace
namespaceSelector:
matchLabels:
environment: production
validate:
message: "Each namespace must have a default-deny NetworkPolicy."
deny:
conditions:
any:
- key: "{{ request.object.metadata.name }}"
operator: NotIn
value: "{{ request.object.metadata.annotations.\"platform.example.com/networkpolicy-verified\" || '' }}"
For deeper coverage of Kyverno policy authoring, the admission webhook architecture, and generating policy reports, see Kyverno controller security.
These policies also apply retroactively via background scanning. Kyverno generates PolicyReport resources with violations for existing workloads. This surfaces drift without requiring a deployment event to trigger the check.
Metrics for Golden Path Adoption
Enforcement via admission control tells you what is blocked. Metrics tell you the current state of the fleet and whether security posture is improving over time.
Four metrics provide meaningful signal:
% of production workloads using approved base images. Query the Kyverno PolicyReport API or scan the container registry for image digests and match them against the approved image list. A Prometheus scraper on the Kyverno policy report endpoint makes this a dashboard metric:
sum(kyverno_policy_results_total{policy="require-approved-base-image", result="pass"})
/
sum(kyverno_policy_results_total{policy="require-approved-base-image"})
% of images with an attached SBOM attestation. Query the registry using cosign verify-attestation against each image digest in the registry. A nightly job that iterates over all images in production namespaces and checks for a valid SBOM attestation (predicate type https://spdx.dev/Document) produces a count that can be exported as a gauge metric.
% of services with an active NetworkPolicy. Count namespaces in application environments that have at least one NetworkPolicy with a non-empty pod selector, divided by total application namespaces. kubectl get networkpolicies --all-namespaces -o json | jq queries can populate this; a kube-state-metrics custom resource metric exporter is cleaner at scale.
Golden path adoption rate. The Backstage catalog tags services created from golden path templates with a label (backstage.io/template: secure-go-service). Services without this label were created outside the golden path. The adoption rate is the fraction of catalog-registered services with the template label. This is the leading indicator — other metrics lag it by the time it takes teams to deploy.
Track these metrics per team and per business unit, not only as fleet aggregates. A fleet-level 95% adoption rate can mask a single team with 0% adoption that happens to run the highest-risk service.
Handling Exceptions
No golden path enforcement survives contact with a sufficiently large organization without a formal exception process. Teams will have legitimate reasons to deviate: a legacy service being migrated incrementally, a third-party operator that deploys its own images, a compliance requirement that mandates a specific base image from a vendor.
The exception process must have four properties to avoid becoming a rubber-stamp:
Time-bounded. Every exception has an expiry date, stored as an annotation on the exempted namespace or workload. A controller watches these annotations and alerts or auto-revokes when the expiry passes:
metadata:
annotations:
platform.example.com/policy-exception: "require-approved-base-image"
platform.example.com/exception-expiry: "2026-08-01"
platform.example.com/exception-reason: "vendor image: Datadog operator requires registry.hub.docker.com/datadog/agent"
platform.example.com/exception-ticket: "SEC-4821"
platform.example.com/exception-approver: "security-team"
A Kyverno policy exception resource provides the actual enforcement bypass. The annotation is metadata for the governance record; the Kyverno PolicyException resource is what tells the webhook to pass the workload:
apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
name: datadog-agent-base-image
namespace: monitoring
annotations:
platform.example.com/exception-expiry: "2026-08-01"
platform.example.com/exception-ticket: "SEC-4821"
spec:
exceptions:
- policyName: require-approved-base-image
ruleNames:
- check-registry
match:
any:
- resources:
kinds:
- Pod
namespaces:
- monitoring
selector:
matchLabels:
app: datadog-agent
Scoped to a specific workload. Exceptions cover named deployments or namespaces, not entire clusters or environments. A namespace-level exception for the monitoring namespace does not exempt unrelated workloads in that namespace.
Auditable. Every exception appears in a central registry (a Git repository of exception YAML files works well). New exceptions require a pull request with approval from the security team. The history of exceptions — granted, expired, revoked — is in the commit log.
Actively expired. A controller (a simple CronJob that runs kubectl get policyexceptions --all-namespaces -o json and checks expiry annotations against the current date) deletes or patches expired exceptions to remove the bypass. Teams receive advance warning via Slack or ticket automation before expiry, giving them time to complete the migration or renew the exception with updated justification.
The combination of admission control enforcement and a governed exception process means the default state is compliance, and non-compliance is visible, time-limited, and attributed to a specific team and decision.
Closing
The golden path is only as valuable as its adoption and enforcement. A Backstage template that developers ignore, with no admission control backing, produces documentation artifacts rather than security outcomes. The model that works is: make the golden path easy enough that using it is the default choice, and enforce it at admission time so that bypassing it requires deliberate action that creates an audit trail.
The investment concentrates in three places: the template itself (written once, maintained by the platform team), the admission control policies (written once, applied to every workload), and the exception process (overhead per exception, not per service). The per-service cost of security drops toward zero as adoption grows. That is the compounding return on the paved road.