2026-06-30 · 4 min read
The Seed Is in the Image
I edited a project description, ran the seed, and watched the site serve the old copy back at me with total confidence.
The edit took under a minute. One project on this site had a description I had grown tired of, so I opened server/seed/data/projects.js, rewrote two sentences, saved, and ran the thing I always run:
docker compose exec server npm run seed
It reported success. I reloaded /lab and read the old description. Reloaded again with the cache disabled. Old description. Checked the API directly. Old description, served cheerfully, with a 200.
I spent the next while looking for a caching bug that did not exist.
Nothing failed, which is why it took so long
The seed had run. It had run correctly. It had simply read a different file than the one I edited.
docker compose exec executes inside the container that is already running, and that container's filesystem came from the image, and the image was built before I touched anything. server/seed/data/projects.js exists in there as a copy frozen at build time. My host edit was never visible to it. The seed opened the file it had, found the old text, and upserted the old text into Mongo with complete success.
Every layer reported health. The command exited zero, Mongo acknowledged the write, the API returned what was in the database. There was no error anywhere in the chain, because on the terms each component understood, nothing had gone wrong. That is the class of bug that costs the most time — not the loud one, the one where the system does exactly what you asked and you were asking for the wrong thing.
The fix is an ordering, and it is now written at the top of the content section of CLAUDE.md:
docker compose up --build -d
docker compose exec server npm run seed
Rebuild first. The image is the source of truth for what the container can see, so the edit has to get into the image before the seed can find it.
The second trap, found on the way out
While I was in there, I removed an entry from the seed file entirely. Rebuilt. Seeded. It was still on the site.
The seed upserts by slug. That is a deliberate property and a good one: it means re-running it is safe, it never duplicates, and applying content is idempotent. It also means the seed only ever inserts and updates. It has no concept of a record that used to be described by the file and no longer is, because it does not compare the file against the database — it walks the file and writes what it finds.
So deletion is genuinely two steps. Remove it from the seed data, then remove the row:
docker compose exec mongo mongo --quiet robotrights \
--eval 'db.projects.remove({slug:"the-slug"})'
I could have made the seed prune. I decided not to. A seed that deletes anything not present in its input is a seed that will one day be run against the wrong database, or against a half-written file, and take content with it. Two steps for deletion is a cost I pay rarely, in exchange for a script that can never destroy something by omission.
What I gave up
Every content change on this site costs an image rebuild. Fixing a typo in a project blurb is the same operation as shipping code. That is slower than a CMS by a wide margin, and I chose it: there are no admin write endpoints on this site, which means there is nothing on the public internet that can modify its content, and the cost of that guarantee is that content lives in files that get baked into an artefact.
For the times when the loop is too slow to tolerate, the dev overlay can bind-mount the seed directory so host edits are visible to the container immediately. That is a local convenience only. The production image still gets rebuilt, because the whole point is that the image is the thing that shipped.
The failure mode I actually want to guard against is not this one. It is the version where I do not notice — where the copy on the site is subtly not the copy in the repository, and both look plausible, and I find out months later reading my own words and not recognising them.