## Production Docker Compose ## Usage: docker compose -f deploy/docker-compose.yml up -d ## ## Required: create .env.production with real secrets before deploying. # Pinned project name — see the matching note in docker-compose.dev.yml. # Both files sit in `deploy/`, so the default project name would be # `deploy` for both and the two stacks would clobber each other's # containers. name: thinkwatch services: server: image: ghcr.io/thinkwatch/think-watch-server:${IMAGE_TAG:-latest} ports: - "${GATEWAY_PORT:-3000}:3000" # Gateway — expose to clients # Console port NOT exposed externally by default expose: - "3001" # Console — only accessible within docker network environment: DATABASE_URL: postgres://${DB_USER:-thinkwatch}:${DB_PASSWORD:?DB_PASSWORD is required}@postgres:5432/${DB_NAME:-think_watch} REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@redis:6379 JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required} GATEWAY_PORT: "3000" CONSOLE_PORT: "3001" # No safe default for CORS_ORIGINS in prod: a localhost default # silently shipped a config that broke real-domain frontends AND, # if the operator was confused enough to relax it, could allow # cross-origin reads from any tab. Fail-fast so misconfig is # visible at boot, not at the first browser request. CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS is required (comma-separated list of frontend origins)} CLICKHOUSE_URL: http://clickhouse:8123 CLICKHOUSE_DB: think_watch CLICKHOUSE_USER: ${CLICKHOUSE_USER:-thinkwatch} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?CLICKHOUSE_PASSWORD is required} # Audit body offload — bodies bigger than audit.body_max_bytes # (default 256 KiB) get uploaded to this S3-compatible backend # instead of being truncated. Defaults point at the bundled # rustfs service so a fresh deploy gets enterprise-grade audit # out of the box. To disable offload entirely, unset S3_BUCKET. S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://rustfs:9000} S3_REGION: ${S3_REGION:-us-east-1} S3_BUCKET: ${S3_BUCKET:-thinkwatch-audit-bodies} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:?S3_ACCESS_KEY_ID is required for body offload} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?S3_SECRET_ACCESS_KEY is required for body offload} S3_PATH_STYLE: ${S3_PATH_STYLE:-true} RUST_LOG: ${RUST_LOG:-info,think_watch=info} # Shared with the `web` service below. Requests carrying it are # believed when they say who the real client is; everything else # is attributed to the address it connected from. Without this the # server sees every user as the nginx container — one shared # rate-limit bucket, useless audit IPs, and sessions that break # whenever that container gets a new address. TRUSTED_PROXY_SECRET: ${TRUSTED_PROXY_SECRET:?TRUSTED_PROXY_SECRET is required (shared with the web service)} depends_on: postgres: condition: service_healthy redis: condition: service_healthy rustfs: condition: service_healthy healthcheck: # The runtime image is distroless/static — no shell, no curl. The # binary probes itself instead (see main.rs `healthcheck` arg); # a curl-based probe here silently kept `server` unhealthy forever # and blocked `web` from ever starting. test: ["CMD", "/think-watch-server", "healthcheck"] interval: 10s timeout: 5s retries: 3 start_period: 15s deploy: resources: limits: cpus: "${SERVER_CPU_LIMIT:-4}" memory: ${SERVER_MEM_LIMIT:-4G} reservations: cpus: "1" memory: 1G networks: - frontend - backend restart: unless-stopped web: image: ghcr.io/thinkwatch/think-watch-web:${IMAGE_TAG:-latest} environment: TRUSTED_PROXY_SECRET: ${TRUSTED_PROXY_SECRET:?TRUSTED_PROXY_SECRET is required (shared with the server)} # Substitute ONLY our variable. The nginx entrypoint's default is # to run envsubst over every `$$VAR` in the template, which would # eat `$$host`, `$$remote_addr` and friends and produce a config # that proxies to nowhere. NGINX_ENVSUBST_FILTER: TRUSTED_PROXY_SECRET ports: - "${WEB_PORT:-80}:80" depends_on: server: condition: service_healthy deploy: resources: limits: cpus: "1" memory: 256M networks: - frontend restart: unless-stopped postgres: image: postgres:18-alpine # Docker's default /dev/shm is 64MB. Production runs with # shared_buffers=512MB and parallel workers, both of which spill # into shm — the default is undersized and crashes show up as # `could not resize shared memory segment` under any non-trivial # load. 1GB matches the prod shared_buffers + parallel-query # working set with comfortable headroom. shm_size: 1g command: - postgres # --- Memory --- - -c - shared_buffers=512MB - -c - effective_cache_size=1536MB - -c - work_mem=16MB - -c - maintenance_work_mem=128MB - -c - wal_buffers=16MB # --- WAL / Checkpoints --- - -c - checkpoint_completion_target=0.9 - -c - min_wal_size=256MB - -c - max_wal_size=1GB - -c - wal_compression=zstd # --- I/O --- - -c - random_page_cost=1.1 - -c - effective_io_concurrency=200 # --- Parallel query --- - -c - max_parallel_workers_per_gather=2 - -c - max_parallel_workers=4 - -c - max_worker_processes=8 # --- Connections & safety --- - -c - max_connections=100 - -c - idle_in_transaction_session_timeout=30s - -c - statement_timeout=60s # --- Planner --- - -c - default_statistics_target=200 - -c - huge_pages=off - -c - jit=off # --- Slow query log --- - -c - log_min_duration_statement=500 - -c - log_checkpoints=on - -c - log_lock_waits=on environment: POSTGRES_USER: ${DB_USER:-thinkwatch} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_NAME:-think_watch} volumes: - ./data/postgres:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-thinkwatch}"] interval: 5s timeout: 5s retries: 5 deploy: resources: limits: cpus: "2" memory: ${PG_MEM_LIMIT:-2G} networks: - backend restart: unless-stopped redis: image: redis:8-alpine # `${VAR:-}` (empty default) silently shipped Redis with NO # password requirement if the operator forgot to set the env. # `${VAR:?msg}` fails compose-up immediately so the misconfig # is visible at deploy time, matching docker-compose.dev.yml. command: redis-server /etc/redis/redis.conf --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required} volumes: - ./data/redis:/data - ./redis/redis.conf:/etc/redis/redis.conf:ro healthcheck: # `redis-cli ping` against a password-protected server returns # NOAUTH and the healthcheck reports unhealthy. The dev compose # already passes `-a $REDIS_PASSWORD`; matching here so the # service actually transitions to healthy. test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 5s timeout: 5s retries: 5 deploy: resources: limits: cpus: "1" memory: ${REDIS_MEM_LIMIT:-1G} networks: - backend restart: unless-stopped clickhouse: image: clickhouse/clickhouse-server:26.3-alpine command: ["--", "--listen_host=0.0.0.0"] environment: CLICKHOUSE_USER: ${CLICKHOUSE_USER:-thinkwatch} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} CLICKHOUSE_DB: think_watch CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 volumes: - ./data/clickhouse:/var/lib/clickhouse - ./clickhouse/initdb.d/:/docker-entrypoint-initdb.d/:ro - ./clickhouse/config.d/:/etc/clickhouse-server/config.d/:ro # Writable, and it has to be: this image's entrypoint rewrites # default-user.xml from CLICKHOUSE_USER / CLICKHOUSE_PASSWORD on # every boot, and fails to start if it can't. What must never # happen is a *second* stack mounting the same directory — see # the note in docker-compose.dev.yml. - ./clickhouse/users.d/:/etc/clickhouse-server/users.d/ healthcheck: # 127.0.0.1, not `localhost`: with `--listen_host=0.0.0.0` CH binds # IPv4 only, while `localhost` resolves to ::1 first inside the # container — the probe got ECONNREFUSED and CH stayed unhealthy. test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"] interval: 10s timeout: 5s retries: 10 deploy: resources: limits: cpus: "2" memory: ${CLICKHOUSE_MEM_LIMIT:-2G} networks: - backend restart: unless-stopped rustfs: image: rustfs/rustfs:latest command: ["server", "/data", "--address", ":9000", "--console-address", ":9001"] environment: RUSTFS_ROOT_USER: ${S3_ACCESS_KEY_ID:?S3_ACCESS_KEY_ID is required} RUSTFS_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:?S3_SECRET_ACCESS_KEY is required} volumes: - ./data/rustfs:/data healthcheck: # RustFS exposes `/health` (200); the earlier-MinIO path # `/minio/health/live` 403s under root-creds mode on current # RustFS builds and would keep the container permanently # "unhealthy", blocking `rustfs_init` from creating the bucket. test: ["CMD-SHELL", "wget -q --spider http://localhost:9000/health || exit 1"] interval: 10s timeout: 5s retries: 5 start_period: 10s deploy: resources: limits: cpus: "${RUSTFS_CPU_LIMIT:-2}" memory: ${RUSTFS_MEM_LIMIT:-2G} networks: - backend restart: unless-stopped # One-shot: create the audit bucket. Runs to completion then exits 0; # safe to re-run on every `docker compose up` because # `mb --ignore-existing` makes bucket creation idempotent. The bucket # lifecycle rule is administered by the app via # `audit.body_s3_lifecycle_days` in the admin UI. rustfs_init: image: minio/mc:latest depends_on: rustfs: condition: service_healthy environment: S3_BUCKET: ${S3_BUCKET:-thinkwatch-audit-bodies} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:?} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?} entrypoint: - /bin/sh - -c - | set -eu mc alias set local http://rustfs:9000 "$$S3_ACCESS_KEY_ID" "$$S3_SECRET_ACCESS_KEY" mc mb --ignore-existing "local/$$S3_BUCKET" echo "rustfs bucket $$S3_BUCKET ready (lifecycle owned by app)" networks: - backend restart: "no" networks: frontend: driver: bridge backend: driver: bridge internal: true