StatsHub Docs

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.

ServiceURLContainerLoopback
webweb.5.9.14.80.sslip.iostatshub-preview-web127.0.0.1:4105
apiapi.5.9.14.80.sslip.iostatshub-preview-api127.0.0.1:4101
docsdocs.5.9.14.80.sslip.iostatshub-preview-docs127.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, so api does 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 — while API_ORIGIN stays http://api:8080. It has to be a second variable rather than a different value for the same one: API_ORIGIN goes through Next's env, 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/health to 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:

AppDev portFrom another device
docs3001https://pitsi-dev.tailb9cb87.ts.net:4001
web3002https://pitsi-dev.tailb9cb87.ts.net:4002
api8080https://pitsi-dev.tailb9cb87.ts.net:4080
expo8142https://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.

On this page