FifeRouter

4 September 2026 · deploys builds

What a release bundle should carry besides the site

A scratch image with no entrypoint, holding the built site, the compose file, the edge config and one deploy script. Each addition was a bug we had already had.

fiferouter-web is an image that runs nothing:

FROM scratch AS bundle
USER 10003:10003
COPY --from=build /src/dist/        /bundle/public/
COPY site/docker-compose.yml        /bundle/docker-compose.yml
COPY site/Caddyfile                 /bundle/Caddyfile
COPY deploy/monitoring/apply-env.sh /bundle/apply-env.sh
CMD ["/this-image-is-a-bundle-not-a-program"]

The deploy creates a container from it and copies the contents out. Nothing is ever executed. Each of those four COPY lines is there because of a specific failure.

The site

The obvious one. Built in CI from a lockfile rather than on the box, so a broken build fails before anything ships and the server never needs node.

That change alone found a dependency undeclared since before anybody remembered — @types/node, present in every developer's node_modules by accident and absent from the lockfile. A clean container is the first thing to ask honestly what the project depends on.

The compose file

This is the one worth the post.

docker-compose.yml describes which images run and which environment variables they receive. It is code, and it was being deployed by a different mechanism from the code it describes.

That produced a bug that cost an afternoon. The credential workflow wrote the Stripe keys into .env on the box. The compose file that would have passed them into the container was in the repository, merged, and not on the server — so the keys arrived, sat in a file nothing read, and the contact form and billing both reported themselves unconfigured while the operator stared at a correctly populated .env.

Shipping the descriptor with the thing it describes makes that state unreachable. A deploy cannot pair this build's HTML with last week's compose file, because they travel in the same image.

The Caddy config

Same argument, one layer out. The edge snippet describes how requests reach the containers this bundle defines; separating them means a routing rule can be one deploy behind the service it routes to.

It is validated before reload, because an invalid snippet takes down fiferouter and the site sharing the box together.

The deploy script's helper

apply-env.sh holds the allowlist of settings the deploy pipeline may set. It lives on the server as the forced command's helper — somewhere CD had no way to reach.

So adding a setting to the repository did not add it to the box, and the gap was only visible at the moment somebody was trying to configure something. That happened twice: once for the spend cap, once for a Postmark token.

Now the bundle carries it and the deploy installs it. The allowlist follows the code that needs it.

ci-deploy.sh itself is deliberately not shipped — it is the entry point pinned in authorized_keys, and a script that rewrites itself while bash is reading it is a way to find out how bash buffers.

Why scratch, and why a CMD

scratch because nothing here executes, so a shell and a package manager would be attack surface in exchange for nothing.

The CMD is required anyway: docker create refuses an image that declares no command, even for a container that will never start — and creating one is exactly how both the smoke test and the deploy get the files out. That failed after the images were already published, which is a good argument for a smoke test that does what the deploy does rather than something adjacent to it.

The USER line is there because Trivy flags an image whose manifest says it runs as root. Nothing runs it, so it is a false positive — but a numeric UID needs no /etc/passwd, of which there is none here, so satisfying it honestly costs nothing and keeps the waiver file for waivers that are actually decisions.

The general shape

Ask of any artifact: what else has to be true on the target for this to work? Every answer is either shipped alongside it or is a version-skew bug waiting for the day the two get out of step.

We found three by having the bug first. The fourth — the Caddyfile — was included by analogy, which is the cheapest way to learn any of them.


← All posts