Host a preview
Run the whole stack in Docker on the Hetzner box, on real URLs, from a commit or the working tree.
bun run deploy:preview builds every service into a Docker image and hosts the
lot on the Hetzner box, behind Caddy, on URLs you can send someone. It is the
same deploy path the push workflow uses — see Hosting for what
the box runs and why there is no external build platform.
Every push to main deploys automatically when the local systemd timer sees the
new remote SHA. Pushing to dev — the default branch, and where the work goes —
does not, so the commands below are how you look at unreleased work: an explicit
redeploy, a partial service build, or an uncommitted snapshot.
PREVIEW_DB_URL='postgresql://…' bun run deploy:preview
PREVIEW_DB_URL='postgresql://…' bun run deploy:preview -- --ref main
PREVIEW_DB_URL='postgresql://…' bun run deploy:preview -- web docs
PREVIEW_DB_URL='postgresql://…' bun run deploy:preview -- --worktree
bun run deploy:preview:status
bun run deploy:preview:downThe link table in the root README.md is generated from the running containers
on every deploy, so it describes what is actually up rather than what was
intended.
One project, one network
Every service goes into a single compose project. That is load-bearing:
docker-compose.yml points web and admin at http://api:8080. Per-app
projects would put each service on its own network and break that name.
Only the published port, the container name and the labels are overridden. The
image is built from the same docker-compose.yml that ships to production, so a
green preview is evidence the real deploy will be green too.
Ports are an implementation detail
Services publish on 127.0.0.1 in the 4100-4199 range and are reachable only
through Caddy. The hostname is the stable address; the port is assigned in
sorted service order and may shift when a service is added.
The local postgres Compose service never comes up in a public preview. Set
PREVIEW_DB_URL to a populated Postgres database. If its hostname is available
only on a Docker network, set PREVIEW_DB_NETWORK too. The deploy stops before
building when the URL is missing, and refuses to publish the API unless its
country list and full tournament catalog contain real product data.
The API also loads its server-side credentials from
apps/statshub-api/.env by default. Set PREVIEW_API_ENV_FILE when they live
elsewhere. The file is required, is attached only at container runtime, and is
never copied into an image. Public product reads remain public; user and admin
routes keep their normal authentication, and the preview explicitly disables
the development-only admin bypass.
It builds a commit, not your desk
The default input is git archive of a real commit: this repo is routinely
mid-refactor with several agents writing to it, and a source tree that changes
halfway through a build produces an image matching no revision.
--worktree opts into the uncommitted tree, and even then the tree is
snapshotted to a temp directory first, so an edit landing mid-build cannot
reach the image.
HEAD does not always build
A commit that exists is not a commit that builds. If bun.lock is stale for
the commit you picked, every Next app dies at bun install --frozen-lockfile
about 40 seconds in. The build loop carries on to the next service and the
summary names what failed — four broken apps should not cost you the two that
work.
URLs
Hostnames are <service>.<domain>, where the domain defaults to this box's IPv4
under sslip.io — it resolves without anyone touching DNS, and Caddy can get a
real certificate for it. Set PREVIEW_DOMAIN to use a wildcard you own.
| Variable | Default | |
|---|---|---|
PREVIEW_DOMAIN | <ipv4>.sslip.io | Host suffix. |
PREVIEW_SCHEME | https | http (or --http) skips the certificate. |
PREVIEW_PORTS | 4100-4199 | Loopback range to allocate from. |
PREVIEW_DB_URL | none; required | Populated Postgres connection used at runtime. |
PREVIEW_DB_NETWORK | none | External Docker network needed to resolve the database host. |
PREVIEW_API_ENV_FILE | apps/statshub-api/.env | Server-side API credentials mounted at runtime. |
NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_DOCS_URL and NEXT_PUBLIC_DASHBOARD_URL
are pointed at the preview's own hostnames, as build args and runtime
environment, so the hosted copy links to itself instead of sending anyone who
clicks a link back to production. API_ORIGIN stays the in-network api:8080,
because that call is made server-side.
These URLs are on the public internet
Caddy serves them to anyone. Within minutes of the API going up its log fills
with scanners probing /login.action and /v2/_catalog. Product data is
intentionally public; authenticated user and admin operations still require
valid credentials. Take the preview down when you are finished.
Taking it down
bun run deploy:preview:down removes the containers and the network, deletes
the Caddy site file, reloads, and rewrites the README table to match. Services
inherit restart: unless-stopped from the compose anchor, so a stack you forget
survives a reboot and keeps holding its ports and certificates.