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.py to 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.

Top: every commit that touched each generation's files, one dot per day, sized by how many commits landed that day. Bottom: the shell generation's size at the end of each day it changed on main, counting 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
generationfirst commitlast editcommitslines when deleted, or now
Vagrant2016-08-272017-08-118102
Ansible and Upstart2017-05-042022-07-2011317
Docker2025-07-132025-10-232347
systemd, nginx and shell2026-03-202026-10-15301,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/trusty64 is 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/hosts still 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:

  1. Turn a fresh box into a running site (install.sh).
  2. Update a running site (deploy.sh, from a GitHub Actions workflow).
  3. Keep the process alive (a systemd unit, 23 lines).
  4. Serve it over HTTPS (an nginx config of 31 lines, and certbot).
  5. 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.