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
only deploy path this repo has — see Hosting for what the box
runs and why there is nowhere else to push.
bun run deploy:preview # the whole stack, from HEAD
bun run deploy:preview -- --ref main # from a specific commit
bun run deploy:preview -- web docs # only these, into the same stack
bun run deploy:preview -- --worktree # the uncommitted working tree
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, and the apps
reach the database as postgres:5432. Per-app projects would put each service
on its own network and break every one of those names.
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.
postgres comes up with the stack but gets no hostname — it serves no HTTP, and
docker-compose.yml says outright it is not part of any deployment. Its compose
default of 5432 is dropped, because the dev database already holds that port.
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. |
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. Do not host a stack
holding real credentials or production data without putting auth in front of
it, and take it 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.