# ───────────────────────────────────────────────────────────────────────────── # sitomat — CONTROL PLANE (ADR-0009 §10, step 1) # # api (Elysia/Bun) :13000 ─┐ # web (Next.js/Bun) :13001 ─┼─ one compose stack, one host # volume `sitomat-data` ─┘ (SQLite + uploads) # # The DATA PLANE (Airflow, MinIO, Trino/StarRocks, OpenMetadata) is a SEPARATE # stack on a SEPARATE host — see ADR-0009 §9. Do not add it here. # # DEPLOY — NO BUILD: the api/web images are pulled from a private registry # (SITOMAT_IMAGE_REGISTRY / SITOMAT_IMAGE_TAG). A deploy host needs only this # file + docker/ + docker/.env — no source, no toolchain. # # cp docker/env.example docker/.env # then edit it # docker compose --env-file docker/.env -f docker-compose.control-plane.yml pull # docker compose --env-file docker/.env -f docker-compose.control-plane.yml \ # run --rm api bun run --cwd apps/api db:migrate # first boot only # docker compose --env-file docker/.env -f docker-compose.control-plane.yml up -d # # Optional reverse proxy (HTTP; add TLS when the real domain exists): # docker compose --env-file docker/.env -f docker-compose.control-plane.yml \ # --profile proxy up -d # # BUILD + PUSH (maintainer host, WITH the source) — see docker/build-push.sh, # which layers docker-compose.build.yml on top of this file: # docker/build-push.sh # ───────────────────────────────────────────────────────────────────────────── name: sitomat-control-plane services: api: # PULL-ONLY: this file is the DEPLOYMENT file. It has NO `build:` section, so # a deploy host needs neither the source nor a toolchain — only the image. # Build + push live in docker-compose.build.yml (see docker/build-push.sh). image: ${SITOMAT_IMAGE_REGISTRY:-sitomat}/api:${SITOMAT_IMAGE_TAG:-latest} restart: unless-stopped environment: NODE_ENV: production PORT: "13000" DB_PATH: /data/sitomat.db WORKSPACE_UPLOAD_DIR: /data/uploads # ── Auth / origin ────────────────────────────────────────────────────── # `BETTER_AUTH_SECRET` is REQUIRED; the `:?` guard makes compose refuse to # start without it instead of silently using a dev fallback. BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in docker/.env} BETTER_AUTH_URL: ${API_PUBLIC_URL:-http://localhost:13000} WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:13001} # Empty = host-only session cookie (single-host). Set to the shared parent # domain (e.g. `.instansi.go.id`) only when web & API are on subdomains. SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-} # ── Keycloak SSO — OPTIONAL: leave all three empty to disable it ─────── OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} volumes: # SQLite + uploads live here. Keep it a LOCAL named volume: the DB runs in # WAL mode and SQLite locking breaks on network filesystems (NFS/SMB). - sitomat-data:/data ports: - "${API_PORT:-13000}:13000" healthcheck: test: - CMD - bun - -e - "fetch('http://127.0.0.1:13000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" interval: 30s timeout: 5s retries: 5 start_period: 20s web: # PULL-ONLY (see the `api` note above). The web build args — including the # DOMAIN-AGNOSTIC `NEXT_PUBLIC_API_URL=""` — are baked when the image is # BUILT (docker-compose.build.yml), not here. image: ${SITOMAT_IMAGE_REGISTRY:-sitomat}/web:${SITOMAT_IMAGE_TAG:-latest} restart: unless-stopped environment: NODE_ENV: production # Server-side Eden client (Server Components / Actions) reaches the API by # its compose service name — this is NOT the browser-facing origin. API_URL: http://api:13000 # The web app's OWN public origin. The demo-login Server Action calls the # API server-side and must present the SAME `Origin` the API trusts — # better-auth rejects a mismatched one with 403 INVALID_ORIGIN. Without # this the action falls back to `http://localhost:13001` and demo login # fails on any host that is not localhost. MUST be a SINGLE origin (the # action puts it straight into the Origin header); if WEB_PUBLIC_URL is a # comma-separated trust list, override this on the web service. WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:13001} depends_on: api: condition: service_healthy ports: - "${WEB_PORT:-13001}:13001" # ── OPTIONAL reverse proxy (profile "proxy") — SINGLE DOMAIN (Opsi A) ─────── # One origin on :8080 serves BOTH the web and the API, split by PATH: a small # set of API prefixes (`/api/`, `/public/`, `/ingest/`, `/docs`, `/health`, # the `/datasets/…/download` regex) goes to the API, everything else to the # web. Add TLS + the real domain when it exists — see # docker/nginx/templates/default.conf.template. proxy: image: nginx:1.27-alpine profiles: ["proxy"] restart: unless-stopped depends_on: - api - web environment: # The SINGLE public hostname the proxy answers for (envsubst → server_name). # One origin serves both the web UI and the API, split by path. PUBLIC_HOST: ${PUBLIC_HOST:-localhost} volumes: - ./docker/nginx/templates:/etc/nginx/templates:ro ports: - "${PROXY_PORT:-8080}:80" volumes: sitomat-data: