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
120 lines
6.3 KiB
Markdown
120 lines
6.3 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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:
|
|
|
|
```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://<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).
|