How this blog is built, checked and deployed
This blog is a pile of Markdown files, a tiny theme and a handful of shell scripts. Publishing a post means pushing to main. About a minute later it is live. This is how the pieces fit together, and what went wrong on the way.
The shape of it
Markdown ──► Hugo ──► public/ ──► rsync ──► web server folder
│
nginx container (read-only mount)
│
reverse proxy + TLS certificate
The site is generated by Hugo. The theme lives in the repository and is deliberately small: no JavaScript, no analytics, no comments, no search. A feed, a sitemap and a 404 page come with it.
The web server is an unprivileged nginx container. The image contains only nginx and its configuration. The site files are not in the image. They are copied to a folder on the server, and the container sees that folder read-only. That means publishing a post never rebuilds or restarts anything: a changed file simply appears.
Everything is a script
All logic lives in scripts/, and the CI workflows only call those scripts. One command runs every check locally:
bash scripts/check.sh
It installs pinned versions of Hugo, a link checker and shellcheck into a local folder (checksums verified, no sudo), builds the site with warnings treated as errors, checks that every internal link works, lints the shell scripts and the workflows, and finally starts the real container to test it: home page, a 404, a changed file showing up without a restart, and a mount that cannot be written to.
If it passes on my laptop, it passes in CI. That is the whole point.
Three pipelines
| Pipeline | When | What it does |
|---|---|---|
| site | every push and pull request | build and check |
| image | push to main when image files change | build and publish the container image |
| deploy | push to main only | build, check, then upload |
The deploy pipeline is a single job on purpose. The upload is the last step, so it can only run after the build and the checks passed for exactly that commit. A pull request can never deploy, and a small policy checker fails the build if someone edits the workflow in a way that would break that promise.
A server login that can do almost nothing
The upload uses SSH, but the key it uses is locked to one forced command: write files into the site folder, nothing else. There is no shell, no ability to read, and no Docker access. The container configuration on the server is owned by root and changed by hand, because whoever can edit it can effectively become root.
What went wrong
Three things broke on the way to the first successful deploy, and each is worth remembering:
set -o pipefailfailed immediately. The CI runner ran my steps withsh, notbash, andshdoes not knowpipefail. Writing plainset -eufixed it.- The deploy key was pasted wrongly. A private key has to keep its line breaks. The deploy script now repairs the common damage and says clearly when a key is unusable, without ever printing it.
- The certificate was refused. A leftover IPv6 (
AAAA) DNS record pointed at an old machine. Let’s Encrypt prefers IPv6, so it validated against the wrong server. Removing the record fixed it. Check both record types.
Built with an assistant, reviewed by another
I built this together with an AI coding assistant. One agent built each piece, and a separate reviewer, who had not seen the builder’s reasoning, tried to break it. Several pieces failed their first review, and the reviewers were right every time. If you do something similar, keep the builder and the reviewer apart.
The repository has a README that explains how to maintain all of this, including how to take the assistant’s access away again.