Deployment
Local hot-reload development
Run the backend and frontend in separate terminals:
just backend
just frontend- Go listens on
http://localhost:8080. - Vite listens on
http://localhost:5173. - Vite proxies
/api,/events, and/wsto Go. just backenduses the persistent, gitignoredbackend/data/dev.dbSQLite database.just backendsetsCOOKIE_SECURE=falsebecause local development uses HTTP.STATIC_DIRstays unset because Vite serves frontend files.
Documentation site
The VitePress documentation is an isolated package. Install its dependencies and start the local development server with:
npm ci --prefix docs
just docsOpen http://localhost:5174. Production output and a local preview are available through just docs-build and just docs-preview.
Build and serve the production-shaped static container with:
just docs-container-build
just docs-container-upThe Compose command enables the profile-gated docs service at the generated worktree hostname with -docs appended. A normal just worktree-up starts only the application service.
The site uses / as its VitePress base and is intended for a dedicated documentation hostname. The deployment platform or reverse proxy should provide that hostname and TLS.
Single-container runtime
Build and run locally with either Docker directly or the worktree-aware Compose wrapper:
docker build -f deployment/Dockerfile -t pointing:local .
docker run --rm -p 3000:8080 -e COOKIE_SECURE=false -v backlog-data:/data pointing:local
# production-shaped worktree stack behind the shared proxy
just worktree-upThe multi-stage build contains dedicated test stages:
frontend-depsinstalls locked Node dependencies.frontend-testruns Svelte and TypeScript checks.frontend-buildcreates the static SvelteKit site.backend-depsdownloads locked Go modules.backend-testruns Go tests andgo vet.testis an aggregate target that succeeds only after both test stages complete.backend-buildcreates the static Go binary.- The distroless runtime receives only the binary, frontend files, and writable data directory.
Run only the containerized validation target with:
docker build -f deployment/Dockerfile --target test -t pointing:test .The final image runs one non-root Go process on port 8080. It serves:
| Path | Function |
|---|---|
/assets/* | Hashed frontend assets with immutable caching |
/ | Prerendered SvelteKit page with no-cache |
/room/* | SvelteKit client fallback with no-cache |
/api/* | JSON API |
/events/* | SSE streams |
/ws/* | WebSocket connections |
The default Compose profile starts exactly one app container and routes its generated pointing.localhost hostname through Traefik.
Participant cookies are restricted to HTTPS by default. The local HTTP Compose configuration sets COOKIE_SECURE=false; internet-facing deployments must leave COOKIE_SECURE=true and terminate TLS at the application ingress.
Set DEFAULT_TRANSPORT to sse (the default) or websocket to choose the initial real-time connection method for browsers that do not already have a saved preference. Users can still switch transports in the application, and their saved choice takes precedence. For example:
docker run --rm -p 3000:8080 -e DEFAULT_TRANSPORT=websocket -v backlog-data:/data pointing:localConcurrent Git worktrees
just worktree-up starts the machine-wide Traefik project if needed, then starts an isolated Compose project for the current Git worktree. DNS-safe branch names remain unchanged. Names that require normalization or truncation receive a stable eight-character worktree hash, preventing branches such as feature/settings and feature-settings from sharing containers or volumes. An explicit recipe argument or POINTING_WORKTREE overrides the readable portion; detached HEADs fall back to the worktree directory name. Use just worktree-url to print the exact hostname.
pointing.localhost is used because its subdomains resolve to loopback without /etc/hosts, dnsmasq, or .local mDNS conflicts. Port 80 must be available. The local setup is HTTP-only and requires Docker Compose 2.22 or newer for worktree-watch.
Useful commands are:
just worktree-up
just worktree-watch # rebuild on source changes; Ctrl-C stops the stack
just worktree-url
just worktree-status
just worktree-logs
just worktree-observability-up
just worktree-down # preserve this worktree's volumes
just worktree-destroy # delete this worktree's volumes
just proxy-down # after every worktree is downOnly Traefik and its pointing-proxy network are shared. Each worktree has independent app and optional PostgreSQL containers, images, default networks, and named volumes. The read-only Docker socket mount gives Traefik broad visibility into local Docker metadata and is intended for development machines.
Observability
The application writes structured JSON logs to standard output. LOG_LEVEL controls verbosity and defaults to info. Request logs use route templates and deliberately omit query strings, room codes, participant IDs, request bodies, names, topics, and votes.
Traces, application metrics, and Go runtime metrics are exported over OTLP/HTTP when OTEL_EXPORTER_OTLP_ENDPOINT is set. Export is disabled when the endpoint is empty, so the normal application profile does not require an observability backend. Common settings include:
OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com:4318
OTEL_SERVICE_NAME=backlog-holdem
DEPLOYMENT_ENVIRONMENT=production
OTEL_METRIC_EXPORT_INTERVAL=60000
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1For local inspection, start the profile-gated Grafana LGTM development image:
just worktree-observability-upThis starts the application with OTLP export enabled and 100% trace sampling. The command prints the worktree-specific Grafana URL ending in -obs.pointing.localhost. LGTM is intended for development and testing, not as the production telemetry store. Application logs remain available through just worktree-logs; production log collection should read container standard output.
The OpenTelemetry metrics reference lists the emitted application and Go runtime metrics, their units, and attributes. Traces follow API requests and WebSocket commands into repository operations without recording room or participant data.
GitLab Observability with Arcane
First enable Observability on GitLab.com:
- For a group project, open the group and select Observe → Setup → Enable Observability. This requires the Developer, Maintainer, or Owner role for the group.
- For a personal-namespace project, open the project and use the same menu as the project Owner. Enabling it applies to projects in your personal namespace.
- Copy the generated OTEL ingestion endpoint and follow any accompanying authentication instructions.
The application image published by GitLab CI includes these defaults. Arcane can override them by setting the matching container environment variable:
| Resource attribute | Image default | Container environment override |
|---|---|---|
gitlab.project.id | CI_PROJECT_ID | GITLAB_PROJECT_ID |
gitlab.project.name | CI_PROJECT_NAME | GITLAB_PROJECT_NAME |
service.version | Full CI_COMMIT_SHA | SERVICE_VERSION |
deployment.environment.name | Application fallback: development | DEPLOYMENT_ENVIRONMENT |
The environment is chosen at deployment time so the same image can run in staging and production. The image's OCI version label retains the release tag or short SHA. vcs.ref.head.revision retains the embedded build revision even when service.version is overridden.
In Arcane, add these environment values to the existing application container or stack, then redeploy:
environment:
DEPLOYMENT_ENVIRONMENT: production
OTEL_SERVICE_NAME: backlog-holdem
OTEL_EXPORTER_OTLP_ENDPOINT: "https://<generated-otlp-ingestion-host>"
# Set only if required by the endpoint's connection instructions:
# OTEL_EXPORTER_OTLP_HEADERS: "<header-name>=<header-value>"Use the generated OTLP/HTTP base endpoint, including any base path. The exporters append /v1/traces and /v1/metrics; do not put /v1/traces on the shared endpoint. If GitLab supplies separate full signal URLs, configure OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and OTEL_EXPORTER_OTLP_METRICS_ENDPOINT in Arcane as well. These override the corresponding exporter URLs; keep OTEL_EXPORTER_OTLP_ENDPOINT set because the application uses it to enable export. Configure credentials at deployment time.
For additional attributes or overrides, set OTEL_RESOURCE_ATTRIBUTES, for example:
OTEL_RESOURCE_ATTRIBUTES: "gitlab.project.id=12345,deployment.environment.name=staging"Explicit resource attributes override application defaults and the metadata environment variables. OTEL_SERVICE_NAME takes precedence over service.name in OTEL_RESOURCE_ATTRIBUTES.
The repository's deployment/compose.yaml builds locally: project metadata and SERVICE_VERSION are optional build arguments, and changing them requires rebuilding. For registry images in Arcane, omit metadata environment entries unless overriding them: an explicitly empty container variable replaces the baked-in image value. Local builds omit missing GitLab project attributes; service version falls back to the embedded full revision when known, then the build version or dev.
To verify the deployment:
- Temporarily set
OTEL_TRACES_SAMPLER=always_onin Arcane and redeploy. - Use the application to generate API requests and room activity. Allow at least one metric export interval (60 seconds by default).
- Open Observe → Services, select
backlog-holdem, and check traces and metrics for the project ID/name, full commit SHA, andproductionenvironment. Resource fields may be displayed separately from metric data-point labels. - Restore the normal sampling settings after verification. If data is missing, inspect container output for exporter errors and confirm the endpoint and authentication settings.
Persistent storage
SQLite writes /data/data.db in the backlog-data volume. Preserve or back up this volume when replacing containers.
The server migrates this database at startup by default. The recommended production rollout applies migrations once before replacing application instances:
DATABASE_AUTO_MIGRATE=false just worktree-migrate
DATABASE_AUTO_MIGRATE=false just worktree-upKeep DATABASE_AUTO_MIGRATE=true for the simpler single-instance workflow. Set it to false when a deployment job runs /app/migrate up; this avoids every application replica attempting deployment work. Back up persistent data before destructive forward migrations.
PostgreSQL remains an external alternative. Supply DATABASE_DRIVER=postgres and DATABASE_URL, or use the optional local profile:
COMPOSE_PROFILES=postgres DATABASE_DRIVER=postgres just worktree-upThat profile starts a second database container and is intentionally not the single-container SQLite topology.
GitLab Container Registry
.gitlab-ci.yml validates merge requests, branches, and tags. Image publication runs only for main and Git tags using GitLab-provided registry credentials.
The project registry path already ends in the repository name pointing. Published tags are:
| Pipeline source | Tags |
|---|---|
main | $CI_COMMIT_SHA, latest |
| Git tag | $CI_COMMIT_SHA, $CI_COMMIT_TAG |
Pull an image with:
docker login registry.gitlab.com
docker pull registry.gitlab.com/morgan.trench/pointing:latestOCI labels capture the GitLab project URL, commit revision, and release/commit version.
The independently deployable documentation image is published under $CI_REGISTRY_IMAGE/docs with the same SHA, latest, and Git tag policy. For example:
docker pull registry.gitlab.com/morgan.trench/pointing/docs:latestProduction considerations
Production Docker Compose
This single-instance example includes its own Traefik service, routes through Docker labels, and keeps invite-code-based anonymous sessions. Possession of a room code allows joining; there is no verified identity or organization boundary.
Download compose.production.yaml into a deployment directory. Point your hostname's DNS at this host and allow inbound TCP ports 80 and 443. Only Traefik publishes host ports.
Create .env alongside the Compose file (replace the image placeholder with a published release tag or commit SHA):
APP_HOST=planning.example.com
ACME_EMAIL=ops@example.com
APP_IMAGE=registry.gitlab.com/morgan.trench/pointing:<release-or-commit>name: backlog-holdem
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
traefik:
image: traefik:v3.7.13
command:
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --entrypoints.websecure.transport.respondingtimeouts.readtimeout=15s
- --entrypoints.websecure.transport.respondingtimeouts.writetimeout=0s
- --entrypoints.websecure.transport.respondingtimeouts.idletimeout=75s
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?Set ACME_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --certificatesresolvers.letsencrypt.acme.caserver=${ACME_CA_SERVER:-https://acme-v02.api.letsencrypt.org/directory}
- --log.format=json
- --accesslog.format=json
- --accesslog.fields.defaultmode=drop
- --accesslog.fields.headers.defaultmode=drop
- --accesslog.fields.names.StartUTC=keep
- --accesslog.fields.names.RequestMethod=keep
- --accesslog.fields.names.DownstreamStatus=keep
- --accesslog.fields.names.Duration=keep
- --accesslog.fields.names.RouterName=keep
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
networks: [ingress]
restart: unless-stopped
logging: *logging
app:
image: ${APP_IMAGE:?Set APP_IMAGE to a release tag or digest}
environment:
COOKIE_SECURE: "true"
DEPLOYMENT_ENVIRONMENT: production
AUDIT_HMAC_KEY: ${AUDIT_HMAC_KEY:?Set AUDIT_HMAC_KEY}
DATABASE_DRIVER: sqlite
DATABASE_PATH: /data/data.db
DATABASE_AUTO_MIGRATE: "false"
HEALTHCHECK_URL: http://127.0.0.1:8080/api/ready
DEFAULT_TRANSPORT: sse
ROOM_IDLE_TTL: 6h
ROOM_ENDED_TTL: 1h
volumes:
- backlog-data:/data
networks: [ingress]
healthcheck:
test: [CMD, /app/backlog-holdem, healthcheck]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
labels:
traefik.enable: "true"
traefik.docker.network: ${COMPOSE_PROJECT_NAME:-backlog-holdem}_ingress
traefik.http.services.pointing.loadbalancer.server.port: "8080"
traefik.http.routers.pointing.rule: Host(`${APP_HOST:?Set APP_HOST}`)
traefik.http.routers.pointing.entrypoints: websecure
traefik.http.routers.pointing.tls.certresolver: letsencrypt
traefik.http.routers.pointing.middlewares: security,requests
traefik.http.routers.pointing-api.rule: Host(`${APP_HOST}`) && PathPrefix(`/api/`)
traefik.http.routers.pointing-api.entrypoints: websecure
traefik.http.routers.pointing-api.tls.certresolver: letsencrypt
traefik.http.routers.pointing-api.priority: "20"
traefik.http.routers.pointing-api.middlewares: security,requests,body
traefik.http.routers.pointing-admission.rule: Host(`${APP_HOST}`) && Method(`POST`) && (Path(`/api/rooms`) || PathRegexp(`^/api/rooms/[^/]+/join$`))
traefik.http.routers.pointing-admission.entrypoints: websecure
traefik.http.routers.pointing-admission.tls.certresolver: letsencrypt
traefik.http.routers.pointing-admission.priority: "30"
traefik.http.routers.pointing-admission.middlewares: security,requests,admission,body
traefik.http.routers.pointing-stream.rule: Host(`${APP_HOST}`) && (PathPrefix(`/events/`) || PathPrefix(`/ws/`))
traefik.http.routers.pointing-stream.entrypoints: websecure
traefik.http.routers.pointing-stream.tls.certresolver: letsencrypt
traefik.http.routers.pointing-stream.priority: "20"
traefik.http.routers.pointing-stream.middlewares: security,requests,streams
traefik.http.middlewares.security.headers.stsseconds: "31536000"
traefik.http.middlewares.security.headers.contenttypenosniff: "true"
traefik.http.middlewares.requests.ratelimit.average: "20"
traefik.http.middlewares.requests.ratelimit.burst: "100"
traefik.http.middlewares.admission.ratelimit.average: "30"
traefik.http.middlewares.admission.ratelimit.period: 1m
traefik.http.middlewares.admission.ratelimit.burst: "30"
traefik.http.middlewares.body.buffering.maxrequestbodybytes: "16384"
traefik.http.middlewares.body.buffering.memrequestbodybytes: "16384"
traefik.http.middlewares.streams.inflightreq.amount: "100"
traefik.http.middlewares.streams.inflightreq.sourcecriterion.ipstrategy.depth: "0"
restart: unless-stopped
logging: *logging
depends_on:
migrate:
condition: service_completed_successfully
migrate:
image: ${APP_IMAGE:?Set APP_IMAGE to a release tag or digest}
entrypoint: [/app/migrate]
command: [up]
environment:
DATABASE_DRIVER: sqlite
DATABASE_PATH: /data/data.db
volumes:
- backlog-data:/data
restart: "no"
logging: *logging
networks:
ingress:
volumes:
backlog-data:Prepare ACME storage, validate, and start:
mkdir -p letsencrypt
touch letsencrypt/acme.json
chmod 700 letsencrypt
chmod 600 letsencrypt/acme.json
docker login registry.gitlab.com
docker compose -f compose.production.yaml config --quiet
docker compose -f compose.production.yaml pull
docker compose -f compose.production.yaml up -dTraefik renews certificates automatically; preserve the letsencrypt directory across upgrades. For initial certificate testing, use a separate deployment directory and set ACME_CA_SERVER=https://acme-staging-v02.api.letsencrypt.org/directory in .env. Staging certificates are not browser-trusted. Use separate ACME storage when switching to production.
The Docker provider reads routing labels through the Docker socket. A read-only socket mount still grants Docker API access; run this example on a trusted Docker host, or point the provider at an existing restricted Docker API proxy. The dashboard is disabled and containers are not exposed by default.
The example assumes Traefik is the internet-facing edge. It uses the socket peer IP for limits, preserves the public Host, and does not enable insecure forwarded-header trust. If another load balancer sits in front, configure its exact trusted IP ranges and the matching rate-limit source strategy; do not accept arbitrary client-provided forwarding headers.
Starting limits are 20 requests/second per IP with a burst of 100, 30 create/join requests per minute per IP with a burst of 30, and 100 concurrent streams per IP. Tune these labels for expected room sizes and offices sharing a public IP. API request bodies are capped at 16 KiB. Buffering is only on API routes: SSE and WebSocket routes remain unbuffered, with no blanket write timeout. HTTP request limits do not limit commands inside an established WebSocket; see the security model for application controls.
Application command budgets default to 10 units/second per participant and 100 per room, each with ten seconds of burst capacity. Add PARTICIPANT_COMMAND_RATE and ROOM_COMMAND_RATE to the app environment to tune them. Keep one app instance: these budgets and realtime delivery are process-local.
Proxy access logs retain only time, method, status, duration, and router name. They omit headers, query strings, and paths containing invite codes. JSON container logs rotate at three 10 MiB files per service. Set centralized retention and restrict access to operators; keep request/body/header capture disabled in any additional logging agents. HSTS applies to this hostname only; enable it once HTTPS is established.
To use an existing Traefik deployment, attach app to its external network, set traefik.docker.network accordingly, and adapt the entrypoint and certificate-resolver labels. Carry over the middleware definitions and streaming route separation.
PostgreSQL variant
Download compose.postgres.yaml alongside the base file. Add a strong URL-safe POSTGRES_PASSWORD to .env (for example, generate one with openssl rand -hex 32) and restrict the file with chmod 600 .env.
services:
app:
environment:
DATABASE_DRIVER: postgres
DATABASE_URL: postgres://poker:${POSTGRES_PASSWORD:?Set a URL-safe POSTGRES_PASSWORD}@postgres:5432/poker?sslmode=disable
DATABASE_MAX_OPEN_CONNECTIONS: 25
DATABASE_MAX_IDLE_CONNECTIONS: 10
networks: [database]
depends_on:
postgres:
condition: service_healthy
migrate:
environment:
DATABASE_DRIVER: postgres
DATABASE_URL: postgres://poker:${POSTGRES_PASSWORD:?Set a URL-safe POSTGRES_PASSWORD}@postgres:5432/poker?sslmode=disable
networks: [database]
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: poker
POSTGRES_USER: poker
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql
networks: [database]
healthcheck:
test: [CMD-SHELL, "pg_isready -U poker -d poker"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
database:
internal: true
volumes:
postgres-data:docker compose -f compose.production.yaml -f compose.postgres.yaml config --quiet
docker compose -f compose.production.yaml -f compose.postgres.yaml up -dCompose merges the app's networks, so it can reach both Traefik and PostgreSQL. PostgreSQL has no published port and is only on the internal database network. This example disables database TLS for that same-host private network; use verified database TLS for an external database. PostgreSQL 18 stores its versioned data directory under /var/lib/postgresql. Switching drivers does not migrate existing SQLite rooms.
Backups, upgrades, and verification
The examples run /app/migrate up as a one-shot job before starting the application, whose DATABASE_AUTO_MIGRATE setting is disabled. Before upgrading, take a backup: stop the app and copy the SQLite volume, or use pg_dump for PostgreSQL. Test restoration into a separate deployment. Preserve database and certificate storage; docker compose down -v deletes named database volumes.
For upgrades, set APP_IMAGE to the new release or digest, pull, and run up -d using the same Compose file arguments. Review migrations before rollback; an older binary may not support the upgraded schema.
Verify HTTPS redirection, secure cookies, create/join/reload, SSE updates after an idle period, WebSocket commands, oversized-body rejection (413), and rate limiting (429). Confirm backend port 8080 and the database are unreachable externally, and check that logs contain no cookies, credentials, invite codes, or votes.
From a checkout, run the opt-in ingress smoke check against a dedicated test deployment using the example's default limits (it creates rooms and deliberately exercises throttling):
cd backend
SMOKE_SECURITY=true SMOKE_BASE_URL=https://planning.example.com go test ./smoke -count=1The application image deliberately does not terminate TLS. Deploy it behind the ingress supplied by the target platform and ensure that ingress:
- supports WebSocket upgrades;
- does not buffer SSE responses;
- permits long-lived connection idle times;
- forwards the original scheme and host appropriately.
Before public exposure, apply the request limits and application protections described in the security model, and configure backups and observability. Keep one application replica until cross-instance realtime delivery and rate-limit coordination are supported.