# 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 ` 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 ```bash git clone https://gitea.cloudfreeiot.com/bkvargyas/courtside.git cd courtside cp .env.example .env ``` Edit `.env`: ``` ADMIN_PASSWORD= SESSION_SECRET= TRUST_CF_ACCESS=false # true if you set up Access in step 2 CLOUDFLARE_TUNNEL_TOKEN= ``` Then: ```bash 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: ```bash 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 ```bash 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: ```bash ADMIN_PASSWORD=letmein docker compose -f docker-compose.local.yml up --build # http://: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/` must pass through the proxy; Cloudflare tunnels support WebSockets by default. As a fallback every public page re-fetches `/t//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).