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.