Three generations of reproducible environments, all deleted
On 2026-07-28, one commit (b93f941) deleted two complete deployment
systems from this repository, 766 lines of configuration in all:
- Vagrant (2016): a local virtual machine shaped like production.
- Ansible, with an Upstart job (2017): playbooks that built EC2 instances from nothing.
- Docker (2025): an image, a compose file, a supervisor config, and a
dev.pyto drive them.
What replaced them had been sitting next to them since March, and it was already the one that worked: a systemd unit, an nginx config, and shell scripts. It still handles every deploy. This is not an argument that the deleted tools are bad. Each of them was the correct answer, on the day it arrived, to the question "how do I make this deploy repeatable?" The argument is about what a one-server, one-person deployment actually needs, and what happens to the parts nobody runs.
sys/scripts (without its test script), sys/systemd, sys/nginx, sys/config and the deploy workflow. The three flat lines are the deleted generations' sizes on the day they went.The numbers as a table
| generation | first commit | last edit | commits | lines when deleted, or now |
|---|---|---|---|---|
| Vagrant | 2016-08-27 | 2017-08-11 | 8 | 102 |
| Ansible and Upstart | 2017-05-04 | 2022-07-20 | 11 | 317 |
| Docker | 2025-07-13 | 2025-10-23 | 2 | 347 |
| systemd, nginx and shell | 2026-03-20 | 2026-10-15 | 30 | 1,081, plus 342 lines of tests |
Each one was right on the day it arrived
Vagrant, August 2016. The problem was "it works on my machine". The
answer was a virtual machine on your machine that matched the server:
ubuntu/trusty64, a private network address, a synced folder. That was
the standard advice, and it was good advice. Fittingly, the synced folder
mounted the project at /opt/j4ne, because the Vagrantfile had been
copied from another project and kept its name.
Ansible, May to August 2017. The problem was building a server from
nothing, repeatably, on EC2. The answer was playbooks, plus an Upstart
job to keep the app running, plus certbot for HTTPS. You can read the
work in the commit messages from three days in August:
stage box for more real local testing, pulling from git with much
gnashing of teeth, building on ec2 instances, nginx using certbot
certs. Eleven commits, almost all in those weeks, then one more in 2022.
Docker, July 2025. The problem, as the commit put it, was to
"replace Vagrant/Ansible with modern Docker setup". It replaced nothing:
the first two stayed in the tree beside it, so for a year the repository
described three different ways to deploy itself. The answer arrived in a
single commit from a coding bot, titled
Phase 2: Infrastructure modernization complete, and it is a good
example of a tool being sold rather than used. Its message promised
multi-stage builds on Ubuntu 22.04, health checks, a /health endpoint,
and vulnerability scanning in CI. The Dockerfile it added has one
FROM python:3.11-slim, which is Debian and single-stage. The app never
had a /health route. It baked
COOKIE_SECRET=dev-docker-secret-change-in-production into its
environment.
It was also written for a site that stopped existing. In July 2025,
master was still the old template-rendered site, and the image would
have served that. Six months later the Svelte merge made web/dist the
whole site, and the image, which never ran a frontend build, would have
served an empty directory. The Dockerfile didn't change. The thing it
described did.
Nothing noticed, because it never ran, and we can show that. The CI job
that was supposed to build the image and curl /health sat behind a
test matrix that listed Python 3.10 unquoted. YAML reads that as the
number 3.1, and no such Python exists, so the tests failed and the
docker-build job never started. From the first CI run on record, in
January 2026, to the commit that deleted Docker, CI ran thirteen times
and skipped docker-build all thirteen. (The matrix had a second problem,
three Pythons for an app that runs on one, which is
a note of its own.)
What rots when nobody runs it
None of the three failed. They couldn't, because nothing exercised them. That is the specific way a second description of a server goes bad: it drifts from the real one, and nothing tells you.
By the time they were deleted:
ubuntu/trusty64is Ubuntu 14.04, out of standard support since April 2019.- Upstart was Ubuntu's init system until 15.04 switched to systemd in
- The 2017 playbooks targeted the old one on an old release.
sys/hostsstill carried the IP address of a server that no longer existed.- The Vagrantfile had last been edited nearly nine years earlier.
- CI had never once built the Docker image.
A deploy tool that is not the thing that deploys is documentation, and it is the worst kind: it looks executable, so people trust it, but nothing checks it.
What one server actually needs
The site runs on one VPS and is maintained by one person. For that, the list of real needs is short:
- Turn a fresh box into a running site (
install.sh). - Update a running site (
deploy.sh, from a GitHub Actions workflow). - Keep the process alive (a systemd unit, 23 lines).
- Serve it over HTTPS (an nginx config of 31 lines, and certbot).
- Prove that what just shipped works.
Environment parity across a team, provisioning a fleet, immutable images you can roll back: those are real problems, and Vagrant, Ansible and Docker are good answers to them. None of them was this site's problem. Each generation was adopted because it was the current answer to "how do I make this repeatable", not because the site had outgrown the previous one.
The shell script was not free
The honest counterweight is in the bottom half of the chart.
Two days after the deletion, install.sh met a real server and took
eleven fix commits in one day: a home directory that had drifted, scripts
that lost their executable bit, passwordless sudo so the service user
could restart itself, certbot needing to own the HTTPS server block, a
nodejs and npm package conflict, and a port collision with another app on
the box. Two SSH keys, not one is the
write-up of that day.
Then on 2026-10-15 it took nine more. A sister project's box ran out of
memory building the frontend mid-deploy and stopped with new code pulled
and old code serving, so the frontend is now built once on the CI runner
and shipped as an artifact. A certificate on that same sister project had
sat expired for 294 days because nothing checked renewal, so
setup-ssl.sh now ends with a renewal dry run. Deploys now wait for CI
and ship exactly the commit CI tested. One .node-version file names the
Node version for CI, the runner and the box.
So the replacement is now 1,081 lines, plus 342 lines that test it. That is bigger than both deleted generations put together. Deleting the abstraction did not make the deploy smaller.
What it did instead
Compare how each generation answered the fifth need, proving that what shipped works.
Docker's answer was a HEALTHCHECK that ran curl -f http://localhost/.
On today's site that check can barely fail. SpaStaticFileHandler
answers 200 with the app shell for any path that is not an asset, so a
blank page or a bundle that throws on load would pass. Status codes say
the server answered. They say nothing about what it said.
The shell script's answer, added in October, waits for the app to come
up and then reads three pages and checks what is in them: that / is the
app shell with the server's own title tags substituted in, that
/api/notes is JSON with a list of notes, and that /notes is the
server-rendered listing and not the shell. The commit message puts the
reason in four words: "Bodies, not status codes".
That is the difference that matters, and it is not about shell versus containers. Every line of the shell script runs on every deploy, against the real box, and several of those lines check the result. The deleted generations were never run, so they never checked anything, and their line counts were small because nothing ever asked them for more.
The test
The useful question about a deploy tool isn't whether it's modern. It's whether it is the thing that deploys. If it is, it will grow, because real servers keep finding new ways to be different from your assumptions, and that growth is the tool doing its job. If it isn't, either start using it or delete it. A second description of the server that nothing runs will drift, and the drift makes no noise.