Back to the portfolio

This site's delivery pipeline

Commit to production with no manual steps and no long-lived secret. It started on Kubernetes and moved to static hosting with a scale-to-zero API once the cost stopped making sense.

GitHub Actions · Terraform · Static Web Apps · Container Apps · Azure SQL · AKS (first version)

~$90 → ~$9 / mo on Kubernetes versus now, from the Azure cost export; today's cost is almost all the Static Web Apps Standard plan

The program

Ship this React site with no manual steps and no long-lived secrets, on a small budget. The first version ran on Azure Kubernetes Service: a free-tier control plane, one node, and the node stopped overnight by a scheduled workflow. The current version serves the page from Azure Static Web Apps, with a Spring Boot API on Azure Container Apps and an Azure SQL database behind the contact form. The API scales to zero and the database auto-pauses when nobody is using them.

The parts that carried over: OIDC workload identity federation instead of a long-lived service principal secret, scoped to the production environment, so there is no secret to leak or rotate. Terraform state in Azure Storage, because a runner is a fresh machine every time and local state would not survive it. CI runs lint, tests, a build, and terraform fmt and validate on every change, and the API's Maven tests on every API change. After each deploy, a smoke test, a real-browser check and a Lighthouse score gate run against the live site. The parts that were about the container and went with it: a multi-stage image, images tagged by commit SHA and deployed by digest, Trivy scanning and build-provenance attestation.

Every pull request deploys the exact build CI tested to a staging environment on the same Static Web App, and the smoke test and browser checks run there; a pull request can't merge until they pass. The production deploy gets its own login, which only the main branch can use.

Terraform is in the pipeline too. Every pull request gets a plan comment from an identity that can read Azure but not change it. After merge, a change waits for my approval, then re-plans and applies only if the plan still matches what I approved. A weekly run fails if Azure has drifted from the code, which is how a hand-made change would get caught.

There is no database password anywhere. The API signs in to Azure SQL as its Container App's managed identity, and password sign-in is switched off on the server, so there's nothing in GitHub, the container's secrets or the Terraform state to leak or rotate.

Why the database never slept

The database should pause after an hour with no connections. It never did: the uptime check woke the API about three times an hour, and every startup connected to the database. Fixing it took two rounds.

Before the fix, every API cold start, about three an hour, connected to the database, so it never reached 60 idle minutes and never paused. After the fix, cold starts don't connect, and the database pauses 60 minutes after the last real connection.

Read the full story

Autopsy

AKS-era numbers, from a real workflow_dispatch run: about 1m38s from dispatch to done. ci 39s, terraform (fmt and validate only) 9s, deploy 53s, and the rollout itself about 5s across two replicas with maxUnavailable 0. The image was 63 MB locally, over my 50 MB target. Both Alpine nginx bases have grown past that upstream, so I kept the number and dropped the target.

I tested the failure paths on purpose. An always-false test left ci red and deploy skipped. A nonexistent image digest left the new pod in ImagePullBackOff while both old pods kept serving, and rollout status failed the job. A green pipeline that can't go red says nothing.

Monitoring then caught a real outage before my planned test did. krahler.com had no DNS records, cert-manager had been installed by hand and lost to a nightly destroy, and the restricted Pod Security policy blocked AKS's own gateway pods. The fix was moving the Gateway into its own namespace, not weakening the policy on the app's. Container Insights also dropped every log line for two hours because Terraform doesn't create the Data Collection Rule the portal does. Terraform applying cleanly told me nothing about either.

On Static Web Apps, Lighthouse (mobile emulation) scores 0.88-0.90 for performance and 1.00 for accessibility, best practices and SEO; desktop performance is 0.98-0.99. It took two rounds: a 404 on a file the code still requested cost best-practices points, and the profile photo was the heaviest thing on the page.

A redesign later dropped mobile performance to 0.82 and the deploy's Lighthouse gate failed, which is the point of having one. The cause was a photo preload that ran before the browser knew the screen size, so phones downloaded the photo twice at the wrong size, plus fonts only found after the CSS loaded. A fading card animation had also cost accessibility points, because Lighthouse measured the text mid-fade.

If I keep going

Kubernetes was more than a one-page static site needs, and I'd rather say so than pretend otherwise. The point of the first version was to build the delivery path properly, not to right-size the workload. Even with the node stopped overnight, the load balancer, public IP and registry billed around the clock, and the site was dark from 10pm to 5am. Moving to static hosting removed both problems. The AKS setup is kept in the repo as a lab I can bring up and tear down, with its own Terraform state.

The cost: about $90 a month on Kubernetes (the node, plus a load balancer, public IP and registry that billed even with the node stopped), against about $9 now. Nearly all of today's cost is the Static Web Apps Standard plan; the API, database and state storage together come to about $0.33 a month, because the database now pauses when nobody is using it. Getting there took a real incident: it never paused at first, and billed about $7.50 to $10 a day until every cold start stopped connecting to it.

Still under-engineered: staging shares the production API and database, so a contact-form test from staging is a real message. A separate staging API and database would fix that, but it roughly doubles the backend bill for a site this size, so I'm weighing it rather than assuming it.

Staging and production are kept apart by GitHub environments, not by credentials: the Static Web Apps deployment token can deploy to either. Separate tokens or identities per environment would close that gap.

Read the code