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
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 versionworks). - 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 psshowsappunhealthy.docker compose logs app. The most common cause is aDB_PATHdirectory thenodeuser 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, notlocalhost). - Organizer login loops.
ADMIN_PASSWORDis unset in.envor contains a character your shell mangled; quote it. WithTRUST_CF_ACCESS=truebut 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.jsonevery 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).