DEV Community

Pradeep Kandepaneni
Pradeep Kandepaneni

Posted on

I Built a Platform Engineering Golden Path That Runs for $0 — Every Decision, and Everything That Broke

Originally published on the AWS Builder Center: https://builder.aws.com/content/3GnbxJrdbgQ5sXNJTRXvHIB5OaV/i-built-a-platform-engineering-golden-path-that-runs-for-dollar0-every-decision-and-everything-that-broke
Repo: https://github.com/PradeepKandepaneni/golden-path

Most "platform engineering" articles show you a diagram. A tidy box labeled Internal Developer Platform, some arrows pointing at happy little developer icons, a paragraph about reducing cognitive load. Then the article ends, and you're holding a concept you can't run, can't fork, and can't check.

I wanted the opposite. The smallest thing a developer could actually use — push code, get back a tested, hardened, deployed workload, no tickets, no clicking — and I wanted it to cost nothing to run, so anyone reading this could clone it and watch it work instead of trusting me. No AWS bill to reproduce it. No "works on my cluster, promise."

So that's what I built, and this is the honest write-up. The repo is public and green on every push, so none of what follows requires taking my word for it: github.com/PradeepKandepaneni/golden-path. Every design decision, why I threw out the alternatives, and — the part actually worth your time — the one bug that took the whole thing down and what it taught me about hardened containers.

What a "golden path" actually is

The term gets thrown around, so let me pin it down. A golden path is the paved road a platform team hands its developers: one opinionated, well-lit route from "I have code" to "it's running," with the boring-but-critical parts already handled. Tests. Immutable image tags. Health checks. A pod that doesn't run as root. The developer doesn't wire those up per service, and doesn't get to forget them either. They're in the road.

Platform engineering, stripped of the conference gloss, is mostly the work of building and maintaining those roads so product teams stop rebuilding CI pipelines and Kubernetes manifests from scratch every time they ship something. Pave the road once; every team that drives on it inherits consistency, security, and speed for free. That's the whole pitch.

The trap — and I nearly walked straight into it — is believing you need a giant portal to deliver any of that. You don't. You need one road that works.

Decision 1: A template repo, not Backstage

The flashy way to demo an Internal Developer Platform is Backstage. It's the name everyone knows, it has a slick UI, and "built an IDP with Backstage" looks great on a repo.

I skipped it, and I'd tell you to skip it too, at least at the start. Backstage is a full-stack app you have to run, host, and babysit. For a solo project meant to show a pattern, that's weeks of wrestling with the portal before you've paved a single road — and a half-finished portal is worse than nothing, because it advertises that you started something big and quit.

A template repository plus GitHub Actions gets you the same idea — one automated path that scaffolds, tests, scans, and deploys a service — with zero portal code. It's also honestly closer to what a lot of real teams run day to day. Golden paths don't need a portal. They need an opinionated, automated pipeline. That's the thing I built, and it's a thing I could finish.

If I had to compress the lesson to four words: ship what you can finish.

Decision 2: kind in CI, not a cloud cluster

Here's the constraint that shaped everything else: the project had to run for free, in CI and on my laptop.

That killed running a real EKS cluster as the default. EKS is the right answer in production — it's on the roadmap as the documented production path — but the control plane alone runs about $0.10 an hour before you add a single node, load balancer, or NAT gateway. Leave one up while you iterate and it quietly turns into a $150–300/month surprise. Worse than the money: nobody can cheaply reproduce your work, which defeats the entire point of a demo repo.

So the default cluster is kind — Kubernetes running inside Docker. Locally, make up creates a cluster, builds the image, loads it, deploys, and smoke-tests it. In CI, the pipeline spins a kind cluster up inside the GitHub Actions runner, deploys to it, checks it, and throws it away when the job ends. My real run created the cluster and had the control plane Ready in 19 seconds:

  • Ready after 19s 💚 Set kubectl context to "kind-golden-path"

The economics are the quiet superpower. On a public repo, Actions minutes are unlimited and free, and the cluster lives and dies inside that free runner. The entire pipeline — a live Kubernetes deploy plus a real HTTP smoke test — costs nothing. Every push. Forever.

yaml

  • name: Create kind cluster uses: helm/kind-action@v1 with: cluster_name: golden-path config: kind/kind-config.yaml

That's the whole "provision Kubernetes" step, and it runs on GitHub's dime.

Decision 3: The image tag is the git SHA. Never :latest

Small, and non-negotiable. The image is tagged with the short git SHA of the commit that built it, and the manifest is pinned to that exact tag before the deploy.

yaml

  • name: Compute image tag from git SHA
    id: vars
    run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"

  • name: Build image
    run: |
    docker build \
    -t golden-path:${{ steps.vars.outputs.sha }} \
    --build-arg VERSION=${{ steps.vars.outputs.sha }} .

:latest is a lie. It's a mutable label pointing at different bytes over time, so "what's running" and "what I think is running" drift apart the second someone pushes again. A content-addressable tag means a running pod can tell you exactly which commit it came from — and this service does, because I bake the SHA into the binary at build time with an ldflags override. You'll see it pay off at the end of the war story below, in the actual smoke-test output.

Tiny discipline. Enormous payoff the first time you're staring at a production incident at 2 a.m. wondering what's actually deployed.

Decision 4: Distroless and non-root, by default

A golden path only earns its name if good practice is the default, not a thing you remember to bolt on. So the service runs on a distroless base — no shell, no package manager, almost nothing to pivot into — and the pod is hardened right in the manifest:

yaml
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

Non-root. No privilege escalation. Read-only root filesystem. Every Linux capability dropped. It's the posture you'd eventually want a cluster-wide policy engine to enforce (Kyverno or OPA, on the roadmap), but here it starts as the baked-in default. The distroless choice also just produces a tiny image — the final artifact came out at 10.4MB, which the pipeline confirmed after loading it onto the node:

docker.io/library/golden-path fbab3d2 ae676b1d9427 10.4MB

And this decision is exactly where the project bit me. Which is the part I actually want to talk about.

The bug: CreateContainerConfigError

I pushed the first real version, opened the Actions tab, and watched it fail. Not at build. Not at cluster creation. At the deploy:

Waiting for deployment "golden-path" rollout to finish: 0 of 2 updated replicas are available...
error: timed out waiting for the condition
Error: Process completed with exit code 1

Zero of two replicas. A three-minute hang, then a red X.

Here's the first trap, and it's worth internalizing: that error is useless. "Timed out waiting for the condition" tells you the rollout didn't finish. It says nothing about why. The pods could be failing to pull an image, crashing on boot, flunking a health check, any of a dozen things. kubectl rollout status flattens all of them into the same timeout.

My honest first guess was an image problem — the tag hadn't matched, the load had failed, something like that. Wrong, as it turned out. But I couldn't even tell I was wrong, because the pipeline wasn't telling me anything.

So my first change wasn't a fix. It was making the pipeline talk. I added a diagnostics step that only fires when the deploy fails and dumps everything about the broken pods:

yaml

  • name: Diagnostics (only if the deploy failed) if: failure() run: | kubectl get pods -o wide kubectl describe pods -l app=golden-path kubectl get events --sort-by=.lastTimestamp | tail -40 kubectl logs -l app=golden-path --all-containers --tail=100 || true

Pushed again. Same timeout — but this time the log told the truth right underneath it:

NAME READY STATUS RESTARTS AGE
golden-path-6445746597-2czz8 0/1 CreateContainerConfigError 0 3m
golden-path-6445746597-svdct 0/1 CreateContainerConfigError 0 3m

CreateContainerConfigError. Not an image problem at all — the image had loaded fine (so much for my first guess). Not a crash — the container never even started. Kubernetes was refusing to create it from its config.

The cause is a genuinely sharp edge. I'd set runAsNonRoot: true, which tells the kubelet to refuse to start a container that would run as UID 0. To enforce that at create time, the kubelet needs to know the numeric user ID the container will run as. My distroless image runs as a non-root user — but it doesn't declare a numeric UID the kubelet can read at that moment. No number to check against, so the kubelet can't prove the container is non-root, and rather than risk running something as root, it refuses. Config error. Pods parked. Rollout hangs. Generic timeout.

One line fixes it: pin the UID so the check has a number. The distroless nonroot variant runs as UID 65532:

yaml
securityContext:
runAsNonRoot: true
runAsUser: 65532 # <- the fix
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

Pushed. Green. And this time the rollout log shows the recovery happening in real time — two replicas coming up over about eight seconds:

Waiting for deployment "golden-path" rollout to finish: 0 of 2 updated replicas are available...
Waiting for deployment "golden-path" rollout to finish: 1 of 2 updated replicas are available...
deployment "golden-path" successfully rolled out

Then the in-cluster smoke test hit the service and got a real response back:

{"message":"hello from the golden path","version":"fbab3d2"}

Look at that version field: fbab3d2. That's the git SHA from Decision 3, baked into the binary, reported by the running pod. The immutable-tag discipline isn't theory in that line — the workload is telling me exactly which commit it came from.

Why give a whole section to one missing line of YAML? Because it's the shape of nearly every hardening bug you'll meet. Doing the secure thing — refusing to run as root — is what caused the failure. Had I left the container running as root like a lazy default, it would've started instantly, and I'd have shipped something worse. Security correctness cost me an explicit config I didn't know I owed, and the failure mode (CreateContainerConfigError, hiding behind a generic rollout timeout) pointed nowhere near the actual cause. That gap — between where it breaks and why it breaks — is where most of the real work in this field lives.

Why "free" is a feature, not a shortcut

It'd be easy to read the zero-cost constraint as me being cheap. It's the reverse. Free-to-run is what makes the work verifiable, and verifiable is the whole point.

Piece Cost
GitHub Actions (public repo) Free, unlimited
Local kind cluster Free (your CPU)
All tooling — Go, Terraform, Argo CD, Syft, Trivy, Kyverno Free / open source
EKS (roadmap, optional) Paid — destroy when done

Because the pipeline deploys to a throwaway cluster and fails the build unless the app actually serves traffic, a green check here doesn't mean "the code compiled." It means "the golden path works, end to end, and here's the run that proves it." Anyone can open the workflow, click into a run, and see the deploy happen — and clone it to reproduce the whole thing locally without spending a cent. That's a different kind of credibility than a screenshot.

What's deliberately not done yet

I want to be straight about scope, because the temptation with a piece like this is to pretend the toy is a platform. It isn't. This is the thin slice: one service, end to end, green. The value is that it's finished and runs, not that it's complete.

Here's what bolts on next, each a self-contained addition to a spine that already works:

Supply-chain security. Generate an SBOM with Syft and scan the image with Trivy in CI, failing the build on critical CVEs. One of the loudest DevOps themes of 2026, and it drops straight into the existing pipeline.
Policy as code. Enforce that hardening posture cluster-wide with Kyverno or OPA Gatekeeper, so the golden path is guaranteed rather than merely suggested. (Which, satisfyingly, would have caught my non-root bug at admission time with a far clearer message than CreateContainerConfigError.)
GitOps delivery. Hand deploys to Argo CD so a commit becomes the source of truth instead of an imperative kubectl apply.
Real infrastructure. A Terraform/OpenTofu module for an EKS cluster as the documented production path — spun up to capture evidence, then terraform destroy'd, because it isn't free and nobody should leave it running.

Building them one at a time, on top of something that already works, is the entire method. A finished thin slice beats a half-built everything, every single time.

Three things I'd hand to anyone building their own

Ship the thin slice first. The failure mode of ambitious platform projects isn't bad architecture — it's never finishing. Get one service deploying end to end with a single green check, then add layers. Everything I built hangs off that spine.

Make your pipeline talk before you make it work. The hours I'd have burned guessing at 0 of 2 replicas available collapsed to minutes the moment I added a diagnostics step. Instrument the failure path early. You'll lean on it constantly.

Hardening forces explicitness, and that's the job. runAsNonRoot without a runAsUser is the small, sharp, real edge between "I read a tutorial" and "I ran this and hit the wall." Secure defaults make you spell things out; learning where they make you spell things out is most of the craft.

The repo is here, green and free to run: github.com/PradeepKandepaneni/golden-path. Clone it, run make up, break it, and tell me what you'd pave next.

Top comments (0)