Files
bkvargyasandClaude Fable 5.1 5ade592384 Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs
Tournament engine (pools, single/double elimination, standings with proper
tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process
Node server with SQLite via node:sqlite and a WebSocket live board, organizer
desk, QR landing page that follows the tournament phase, Dockerfile and
compose with cloudflared, and documentation for deploying and running a day.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-03 20:31:46 +00:00

6.3 KiB

Deploying Courtside

Target: a Docker host (Compose v2) with outbound internet, exposed to the public through a Cloudflare Tunnel. No inbound ports are opened on the host. This guide is written so that a person or an automated agent can follow it top to bottom.

1. Prerequisites

  • Docker Engine 24+ with the Compose plugin (docker compose version works).
  • A Cloudflare account with a zone (domain) on it, e.g. example.net.
  • A hostname to use, e.g. vb.example.net.

2. Create the Cloudflare Tunnel

In the Cloudflare dashboard: Zero Trust → Networks → Tunnels → Create a tunnel → Cloudflared. Name it (e.g. courtside). On the connector step, copy the token from the docker run ... --token <TOKEN> command; that is CLOUDFLARE_TUNNEL_TOKEN. You do not need to run the command they show.

On the Public Hostname tab add one route:

Field Value
Subdomain vb (or whatever you chose)
Domain your zone
Type HTTP
URL app:3000

app is the Compose service name; cloudflared runs on the same Compose network and resolves it. Cloudflare will create the DNS record for you.

Optional but recommended, protect the organizer desk with Cloudflare Access: Zero Trust → Access → Applications → Add an application → Self-hosted. Application domain vb.example.net, path admin. Add a policy allowing your email (one-time PIN login is fine). Then set TRUST_CF_ACCESS=true in .env so organizers who pass Access are signed in automatically and no app password is needed. ADMIN_PASSWORD continues to work as a fallback when set. Note: TRUST_CF_ACCESS trusts the Cf-Access-Authenticated-User-Email header; only enable it when the app is reachable exclusively through the tunnel (which is the case with the provided compose file, since no host ports are published).

3. Configure and start

git clone https://gitea.cloudfreeiot.com/bkvargyas/courtside.git
cd courtside
cp .env.example .env

Edit .env:

ADMIN_PASSWORD=<a strong password>
SESSION_SECRET=<output of: openssl rand -hex 32>
TRUST_CF_ACCESS=false            # true if you set up Access in step 2
CLOUDFLARE_TUNNEL_TOKEN=<token from step 2>

Then:

docker compose up -d --build
docker compose ps                # app should be "healthy", cloudflared "running"
docker compose logs -f cloudflared   # look for "Registered tunnel connection"

Open https://vb.example.net. You should see the (empty) tournaments list. Open https://vb.example.net/admin, sign in, create a tournament, and open its QR page.

4. What the deployment consists of

Piece Where Notes
app container image built from Dockerfile (node:22-alpine) Runs node server/index.js as the unprivileged node user. Listens on 3000 inside the network only. Healthcheck at /healthz.
cloudflared container cloudflare/cloudflared:latest Outbound-only connection to Cloudflare. Starts after app is healthy.
courtside-data volume Docker named volume mounted at /data Contains courtside.sqlite (+ -wal/-shm). This is the only state.

Environment variables the app reads:

Variable Default Meaning
PORT 3000 Listen port.
HOST 0.0.0.0 Bind address.
DB_PATH ./data/courtside.sqlite (image: /data/courtside.sqlite) SQLite file; directory is created if missing.
ADMIN_PASSWORD unset Organizer password for /admin/login.
SESSION_SECRET random per start HMAC key for the organizer cookie. Set it so logins survive restarts.
TRUST_CF_ACCESS false Accept Cloudflare Access identity header as organizer login.

The app trusts X-Forwarded-Proto and X-Forwarded-Host (which cloudflared sets) to build the absolute URLs used in QR codes and team links. If you put a different reverse proxy in front, make sure it sets those headers.

5. Backups

Everything is in one SQLite file. A consistent copy while running:

docker compose exec app node -e "
  const {DatabaseSync}=require('node:sqlite');
  new DatabaseSync('/data/courtside.sqlite').exec(\"VACUUM INTO '/data/backup-'||strftime('%Y%m%d-%H%M','now')||'.sqlite'\")"
docker cp "$(docker compose ps -q app)":/data/ ./backups/

Or simply stop the stack and copy the volume. Restoring is copying the file back to /data/courtside.sqlite and restarting app. A tournament day produces well under a megabyte.

6. Updating

git pull
docker compose up -d --build

The database schema is created with CREATE TABLE IF NOT EXISTS; state is stored as JSON documents, so engine changes do not require migrations. If a future version changes the JSON shape, its release notes will say so.

7. Running without Cloudflare

For a LAN-only event or local testing:

ADMIN_PASSWORD=letmein docker compose -f docker-compose.local.yml up --build
# http://<host-ip>:3000

QR codes will embed whatever host the organizer used to open the desk, so open the desk via the LAN IP, not localhost, before printing. Browser notifications on team pages require HTTPS on most phones; on plain HTTP the team page still updates live and shows in-page toasts.

8. Troubleshooting

  • docker compose ps shows app unhealthy. docker compose logs app. The most common cause is a DB_PATH directory the node user cannot write; the provided compose uses a named volume that is created with the right ownership.
  • Tunnel connects but the site shows Cloudflare error 502. The public hostname URL must be http://app:3000 (service name, not localhost).
  • Organizer login loops. ADMIN_PASSWORD is unset in .env or contains a character your shell mangled; quote it. With TRUST_CF_ACCESS=true but no Access policy on /admin, the header is absent and password login is used.
  • Live board doesn't update. The WebSocket at /ws/<slug> must pass through the proxy; Cloudflare tunnels support WebSockets by default. As a fallback every public page re-fetches /t/<slug>/state.json every two minutes.
  • Wrong hostname in QR codes / team links. The app builds URLs from X-Forwarded-Host; check the proxy sets it.

9. Resource expectations

A 16-team day with 40 phones open uses a few tens of MB of RAM and negligible CPU. The container has no memory limit set; add one in compose if you want (mem_limit: 256m is generous).