Single-host Docker Compose
docker/compose/prod.yaml runs the whole application on one host with no cloud account
of any kind. It is the same production image, run as three services: web
(nginx + php-fpm), worker (the messenger consumer, which also runs everything
on the schedule) and database (Postgres).
A fourth, mercure (the hub), sits behind a compose profile and stays off
unless you ask for it — site-review push is the only thing that needs it. To
enable it, set MERCURE_JWT_SECRET and MERCURE_PUBLIC_URL in
docker/compose/prod.env, give the hub’s hostname a route in your reverse proxy, and
add --profile mercure to every docker compose command for this stack.
cp docker/compose/prod.env.example docker/compose/prod.env # then fill it in
# Only if you cannot pull the published image — see below.docker compose -f docker/compose/prod.yaml --env-file docker/compose/prod.env build
docker compose -f docker/compose/prod.yaml --env-file docker/compose/prod.env up -d
# Once per deploy, never from a container's entrypoint:docker compose -f docker/compose/prod.yaml --env-file docker/compose/prod.env \ run --rm web docker/prod/release.sh--env-file is not optional. Without it Compose reads the repository’s
.env, which is the development configuration. Every setting with no safe
default is guarded, so a forgotten flag aborts the command instead of starting a
misconfigured instance.
On the build step. LOUPE_PROD_IMAGE defaults to this project’s own
package, and a published GHCR package is not necessarily one you can pull:
package visibility is configured separately from repository visibility, so a
public repository does not imply a pullable image. Both web and worker carry
a build definition for exactly that case — run build once and the stack is
self-sufficient, with no registry access needed at all. If you are pushing your
own image instead, set LOUPE_PROD_IMAGE and skip the step.
What you still have to provide:
INSTALL_TOKEN, before the first deploy. It gates/install, which is where the first administrator comes from — registration will not create one. The wizard fails closed in production, so an unset value means/installreturns 404 and there is no browser route to an account at all. The${INSTALL_TOKEN:?}guard inprod.yamlstops the stack rather than let that happen. First run walks the wizard through.- A reverse proxy. Both published ports bind to loopback. Terminate TLS in
front, forward
X-Forwarded-ProtoandX-Forwarded-For, and setTRUSTED_PROXIESif that proxy reaches the app from a public address. - An SMTP server for
MAILER_DSN. Email verification is mandatory, so registration does not work without one. - A hostname for the hub.
MERCURE_PUBLIC_URLis a separate host that the bridge CLI subscribes to directly; route it to themercureservice. - Backups of the
database_dataandexportsvolumes.
Unlike App Platform, this topology can share a filesystem, so EXPORT_STORAGE
stays at local and both containers mount the same exports volume. That is
also why the worker runs its consumer as www-data rather than root: archives
are written 0600, and a root-written archive would be unreadable to the web
container’s php-fpm workers.