I Ran a Static Site on Kubernetes for Nine Months. Then I Moved It to a Bucket.
Leer en español →This site is a handful of HTML files. Astro builds it, and what comes out is a folder called dist with pages, some CSS, and a few images. Nothing in it needs a server. For nine months I served it from my Kubernetes cluster anyway.
I want to tell you how that worked, why I stopped, and what replaced it.
The setup I was proud of
The old pipeline was a nice piece of work, and I mean that. A multi-stage Dockerfile built the site in a Node container and copied dist into nginx:alpine. The final image was about 40 MB. It ran as the nginx user, listened on 8080 instead of 80, and exposed /health and /ready for the liveness and readiness probes. Restricted pod security standard, no complaints from Kyverno.
Publishing a blog post looked like this. I write in Obsidian. The src/content/blog folder in the repo was a symlink into my vault, so locally Astro read the posts straight from Obsidian. To publish, a script called publish.sh swapped the symlink for real files, committed them, created a tag like 1.2.3, and pushed. Gitea Actions picked up the tag, built the image, pushed it to my Nexus registry, and then opened a commit against my Flux repo updating the HelmRelease with the new tag. Flux reconciled within fifteen minutes, or right away if I ran flux reconcile by hand. Then the script restored the symlink.
Every blog post was a release. With a version number. For a text file.
What it actually cost me
Not money. Attention.
Every post went through Docker, a registry, a Helm chart, and a GitOps reconcile loop. Four systems that could each have a bad day. In February I spent an afternoon consolidating five CI jobs into one and adding Docker layer caching, because builds were slow. In April I chased a bug where an empty --build-arg overrode the Dockerfile default for the Spotify API URL and the site silently fell back to an endpoint that does not exist. These were real problems. They were also problems that a static site should never have.
And there was the quiet one. When the power went out at home, or a node got cordoned, or I was upgrading Talos, my personal site went down with the cluster. The site that is supposed to tell people I know how to run things.
I had built a very good deployment pipeline for the wrong workload. The cluster was the point, and the site was there to justify it. That is pride, not engineering.
The move
On July 7 I added a second workflow, release-to-s3.yaml, and disabled the tag trigger on the Docker one. Six days later I changed the new workflow to run on every push to main that touches src/, public/, the Astro config, or the lockfile. No tags. No version numbers. Push, and in about a minute the site is updated.
The infrastructure lives in its own Terraform repo. A private S3 bucket, a CloudFront distribution reading from it through Origin Access Control, an ACM certificate validated through Cloudflare DNS, and one CloudFront Function. That function matters more than it looks. Astro produces directory-style URLs, so /about is really /about/index.html on disk. S3 does not know that. A tiny JavaScript function at the edge rewrites the URI before it reaches the bucket. The sister site I built for my wife’s practice did not need it, because that one is flat .html files. This one did.
The deploy itself is two sync commands:
aws s3 sync dist/ s3://${S3_BUCKET} \
--delete \
--exclude "_astro/*" \
--cache-control "public, max-age=3600" \
--metadata-directive REPLACE
aws s3 sync dist/_astro/ s3://${S3_BUCKET}/_astro/ \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE
The first one handles HTML and everything that is not fingerprinted. One hour of cache, and --delete so removed pages actually disappear. The second one handles /_astro/, where every file has a content hash in its name. Those get a year and immutable, because if the content changes the filename changes too. After both syncs, a CloudFront invalidation on /* and a message to my Mattermost channel with the run link and how many files were uploaded and deleted.
One more thing I learned the first week. My Gitea runners sit at home, and registry fetches flake sometimes. Deploy run 3001 died on one. I wrapped the install steps in a three-attempt retry loop and have not thought about it since.
What I kept
The Dockerfile, the Nginx config, and the Helm chart are still in the repo. The old workflow is still there too, but it only runs if I trigger it by hand with a version number. If CloudFront ever gives me a reason, I can build an image and point the cluster at it in a few minutes. I do not expect to. It costs nothing to keep.
I also kept the one truly dynamic thing on the page. The “Listening right now” card calls a small Lambda behind API Gateway that talks to Spotify with a refresh token. The URL gets baked into the client JavaScript at build time through PUBLIC_SPOTIFY_API_URL. A static site with one serverless function is still a static site. That part was never the problem.
What I would tell you
If your site is files, host it as files. Kubernetes is where I run Gitea, Postgres, Mattermost, a dozen apps that need to be processes. My homepage is not a process. Moving it off the cluster made the cluster simpler and the site more reliable, and it took two commits.
The old pipeline still works, and I still like it. It just belongs to a different kind of workload.