Hosting
Where the deployables actually run — one Hetzner box, Docker Compose behind Caddy, and no cloud platform in the path.
Everything StatsHub serves runs on a single Hetzner server, and that same box is
the machine the repo is developed on. There is no PaaS account or remote build
farm. A local systemd timer deploys every new commit pushed to main; the
manual command uses the same script and the same health gates.
One box, every service
bun run deploy:preview builds each service into a Docker image and runs the
lot as one Compose project, statshub-preview, on one network. Containers bind
loopback only; Caddy is the sole public listener.
| Service | URL | Container | Loopback |
|---|---|---|---|
web | web.5.9.14.80.sslip.io | statshub-preview-web | 127.0.0.1:4105 |
api | api.5.9.14.80.sslip.io | statshub-preview-api | 127.0.0.1:4101 |
docs | docs.5.9.14.80.sslip.io | statshub-preview-docs | 127.0.0.1:4103 |
Ports are handed out in sorted service order from PREVIEW_PORTS. web reaches
the API as http://api:8080, which is why the hosted services stay in one
Compose project.
The link table in the root README.md is regenerated from the running
containers on every deploy, so it describes what is up rather than what was
intended.
A push deploys on this machine
scripts/deploy/preview.sh takes git archive of a commit, builds images from
that tarball, and starts them. The build itself never leaves the Hetzner box.
statshub-preview-deploy.timer checks origin/main every 30 seconds. When the
SHA changes, scripts/deploy/preview-on-push.sh loads the database connection
from local mode-0600 ~/.config/statshub/preview.env and runs the ordinary
preview deployer with that exact commit. A file lock prevents two deployments
from rewriting the Compose project or Caddy file at the same time.
That environment file also supplies PREVIEW_APIV2_CURSOR_SECRET, a private
value of at least 32 bytes. Public deployments pass it to the API explicitly;
they cannot use the predictable development cursor key. Containers for
services removed from Compose are deleted before ports are assigned, so an old
service cannot retain a loopback port needed by the current stack.
main, and only main. dev is the default branch and the one everything is
committed to, and pushing to it deploys nothing — which is the point of the
split. A release is a merge of dev into main, and the timer treats it like
any other new SHA.
The watcher records an attempted SHA before building. A failed commit therefore
does not rebuild every 30 seconds; push another commit, or run
scripts/deploy/preview-on-push.sh --force after repairing local infrastructure.
You can still deploy an unpushed local commit with bun run deploy:preview.
That manual path is useful while repairing deployment infrastructure, but it is
not what keeps the hosted stack current.
A commit that exists is not a commit that builds
The default ref is HEAD, and HEAD is whatever six agents last landed. If
bun.lock is stale for that commit, every Next app dies at bun install --frozen-lockfile about forty seconds into its Docker build. Deploy a ref you
chose — --ref <sha> — when it matters.
The API has to be up while web builds
statshub-web prerenders its entity routes — team, player, fixture, referee —
from the sitemaps the Go API serves, so next build makes real HTTP requests to
/api/sitemap-*.xml before it can enumerate a single path. With
cacheComponents on, Next rejects a generateStaticParams that returned
nothing, so an API that is down no longer yields a thinner build. It yields a
failed one, in whichever entity route happened to be collected first.
Two lines in docker-compose.yml are what make it reachable:
web's build runs on the host network. A build container is not attached to the compose network, soapidoes not resolve there, and BuildKit refuses to join a project network by name.- The build reads
BUILD_API_ORIGIN— the loopback port the api service publishes — whileAPI_ORIGINstayshttp://api:8080. It has to be a second variable rather than a different value for the same one:API_ORIGINgoes through Next'senv, which inlines it, so a build-time value for it is also the value the running server uses. Setting it to the loopback address once produced a container that proxied/api/healthto a port that does not exist inside it.
scripts/deploy/preview.sh builds api first, starts it, waits for its
healthcheck, and only then builds anything else. Building every image and
starting the stack afterwards left the API down for the one build that read it.
Outside Docker the rule is the same. bun run build:web resolves the API
through the registry — http://localhost:8080 unless API_ORIGIN says
otherwise — so bun run dev:api has to be running, or that variable has to name
something that is. The build fails with the origin it tried and both ways to fix
it, rather than with a Next error about static params.
A populated database is a deployment requirement
PREVIEW_DB_URL has no default. The deployer never starts the local Compose
Postgres, and it checks both reference countries and the full tournament
catalog before web builds or Caddy publishes the API. A healthy but partial
API is a failed deployment.
Caddy owns the edge
scripts/deploy/preview.sh writes /srv/caddy/preview-statshub.caddy and
reloads Caddy. The file is generated on every deploy and carries a header
saying so; hand-edits are overwritten. Each entry is a one-line
reverse_proxy from a hostname to a loopback port.
Hostnames are service plus the domain, which defaults to this box's IPv4
under sslip.io. sslip.io resolves any name containing an IP address to that
address, so web.5.9.14.80.sslip.io points here without anyone creating a DNS
record — and because it is a real public name, Caddy can get a real certificate
for it. Set PREVIEW_DOMAIN to serve a wildcard you own instead.
These URLs are on the public internet
Caddy serves them to anyone who asks. Within minutes of the API going up its
log fills with scanners probing /login.action and /v2/_catalog. Product
reads intentionally expose the populated database, while the deployer mounts
the API credential file and forces normal user/admin authentication. Take it
down with bun run deploy:preview:down when you are done.
Healthy does not mean correct
Every container ships a healthcheck, and each one asks a single question:
does /api/health answer. A Next app whose static assets are missing answers
it perfectly — the route is server-rendered, the container reports healthy,
Caddy proxies happily, and the site renders as unstyled HTML because every
/_next/static/* request 404s.
That is not hypothetical. The statshub-* directory rename left
scripts/prepare-standalone.mjs copying public/ and .next/static/ into
.next/standalone/apps/web while the server ran from apps/statshub-web, and
the stack reported five healthy services while web served no CSS at all. The
scripts now derive that path from the package directory so a rename cannot
repeat it.
When you check a deploy, fetch a stylesheet, not just the health route.
Reaching a dev server from your own machine
The deployed stack is public, but the dev servers are not: ufw opens 22, 80,
443 and Tailscale's UDP port to the internet and nothing else. 5.9.14.80:3002
cannot work from your laptop and should not — that is an unauthenticated dev
server with a database behind it.
Tailscale carries them instead, and tailscale serve publishes each one on the
box's own hostname with a real certificate. The mapping is the dev port with a
4 in front:
| App | Dev port | From another device |
|---|---|---|
docs | 3001 | https://pitsi-dev.tailb9cb87.ts.net:4001 |
web | 3002 | https://pitsi-dev.tailb9cb87.ts.net:4002 |
api | 8080 | https://pitsi-dev.tailb9cb87.ts.net:4080 |
expo | 8142 | https://pitsi-dev.tailb9cb87.ts.net:4142 |
The proxy is tailnet-only — the same port on the public IP refuses the
connection — and the config lives in tailscaled, so it survives a reboot.
tailscale serve --https=4002 off removes one, tailscale serve reset removes
all of them. HMR rides through it: the browser opens
wss://…:4002/_next/hmr and hot reload behaves as it does locally.
Why not just use the port over plain HTTP
http://100.72.225.106:3002 works and needs no setup. It is also the URL a
browser fights you over: type the address without http:// and Chrome and
Safari try HTTPS first, find nothing listening, and report a failure that
reads like the server is down. Serving real TLS removes that whole class of
confusion, and it is what makes the same URL work on a phone.
There is no platform to configure
Railway was removed from this repo: the scripts, the project id and the status command are gone, and nothing deploys there. The Dockerfiles still carry a comment block naming Coolify build-pack fields (base directory, Dockerfile location, exposed port). That describes how an image can be built by a Dockerfile build pack, and it is accurate — but no Coolify instance runs here, and no deploy in this repo goes through one.
If StatsHub ever moves to a managed host, the images are already the interface:
each app has a Dockerfile taking the repository root as its build context, and
docker-compose.yml is the contract between them.