# The control plane without Cloudflare: the Node host, an S3-compatible # object store for media and backups, and a Litestream sidecar replicating # the global store. This is the stack the AWS instance runs and the local # stand-in for it. The web app is not part of it (it stays on Vercel; run # `next dev` locally). # # cp .env.example .env # then fill it in # docker compose up --build # # Only the app reads .env; the sidecars get the few variables they need by # name. The app's port is published on APP_BIND_ADDRESS (loopback unless # .env says otherwise) and the object store's on loopback only, so nothing # here listens on a public interface by default. See # docs/CONTROL_PLANE_CONTAINER.md. services: app: build: context: . dockerfile: packages/control-plane/Dockerfile image: open-inspect-control-plane:local env_file: .env # Pinned here so a .env edit cannot move the host off its volume, its # published port, or its drain budget; PORT in .env is the host-side port # below. The stop grace period must outlast SHUTDOWN_TIMEOUT_MS plus the # 5 s the host keeps before forcing its own exit, so both live here. environment: HOST: 0.0.0.0 PORT: "8787" DATA_DIR: /data SHUTDOWN_TIMEOUT_MS: "30000" stop_grace_period: 40s ports: - "${APP_BIND_ADDRESS:-127.0.0.1}:${PORT:-8787}:8787" volumes: - control-plane-data:/data depends_on: object-store: condition: service_healthy restart: unless-stopped # SeaweedFS as the S3-compatible store for media and backups, pinned by # digest to the multi-arch index so amd64 CI and arm64 laptops run the same # release. Credentials are required on every S3 request. # # `weed server` runs every component in this one container. Only the S3 # gateway listens beyond it; the master, volume server and filer bind to # loopback, since nothing outside the container talks to them. Each start # generates the filer signing key the components share. The buckets named # in S3_BUCKET are created once S3 answers. # # The shell stays as PID 1 to create the buckets, and forwards SIGTERM to # the server. The filer can take 10 s to close its streams, so the stop # grace period outlasts it. object-store: image: chrislusf/seaweedfs:4.47@sha256:ce9e796f1fe6f06968f4c04bdaf8f678dad9c8acdfef3d244133d71bfa6bf882 entrypoint: ["/bin/sh", "-c"] command: - | WEED_JWT_FILER_SIGNING_KEY="$$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" export WEED_JWT_FILER_SIGNING_KEY /entrypoint.sh server -ip=127.0.0.1 -ip.bind=127.0.0.1 -s3.ip.bind=0.0.0.0 \ -master.peers=none -master.telemetry=false -volume.preStopSeconds=1 \ -filer -s3 -s3.port=9000 -s3.port.iceberg=0 -s3.port.lance=0 & server=$$! trap 'kill -TERM "$$server"' TERM INT until curl -sf -o /dev/null http://localhost:9000/healthz; do kill -0 "$$server" 2>/dev/null || exit 1 sleep 1 done IFS=, for bucket in $$S3_BUCKET; do echo "s3.bucket.create -name $$bucket" | weed shell -master=127.0.0.1:9333 >/dev/null done wait "$$server" || wait "$$server" stop_grace_period: 20s environment: AWS_ACCESS_KEY_ID: ${OBJECT_STORE_ROOT_USER:-objectstoreadmin} AWS_SECRET_ACCESS_KEY: ${OBJECT_STORE_ROOT_PASSWORD:?OBJECT_STORE_ROOT_PASSWORD must be set; generate one with openssl rand -hex 16} S3_BUCKET: ${OBJECT_STORE_BUCKET:?OBJECT_STORE_BUCKET must name the media bucket},${LITESTREAM_BUCKET:?LITESTREAM_BUCKET must name the backup bucket} # Loopback only: the app and the sidecar reach it over the compose network. ports: - "127.0.0.1:9000:9000" volumes: - object-store-data:/data # Healthy once S3 answers and every bucket exists, so the app and # Litestream never start against a store without its buckets. The filer is # asked, over loopback, because S3 answers an unsigned request 403 whether # or not the bucket exists. Checks run every 2 s until the first passes, # then every 30 s; an engine older than Docker 25 skips the 2 s phase. healthcheck: test: - CMD-SHELL - >- curl -sf -o /dev/null http://localhost:9000/healthz && IFS=, && for bucket in $$S3_BUCKET; do curl -sf -o /dev/null "http://localhost:8888/buckets/$$bucket/" || exit 1; done interval: 30s timeout: 3s retries: 3 start_period: 60s start_interval: 2s restart: unless-stopped litestream: image: litestream/litestream:0.3.13 # The app's user, so the shadow WAL it writes beside the database is # owned like the database. user: "1000:1000" command: replicate -config /etc/litestream.yml environment: LITESTREAM_BUCKET: ${LITESTREAM_BUCKET:?LITESTREAM_BUCKET must name the backup bucket} LITESTREAM_ENDPOINT: ${LITESTREAM_ENDPOINT:-} LITESTREAM_ACCESS_KEY_ID: ${LITESTREAM_ACCESS_KEY_ID:-} LITESTREAM_SECRET_ACCESS_KEY: ${LITESTREAM_SECRET_ACCESS_KEY:-} volumes: - control-plane-data:/data - ./packages/control-plane/docker/litestream.yml:/etc/litestream.yml:ro depends_on: app: condition: service_healthy object-store: condition: service_healthy restart: unless-stopped # Optional TLS termination: `docker compose --profile tls up` with # CADDY_DOMAIN set and ports 80/443 reachable from the internet. caddy: image: caddy:2-alpine profiles: ["tls"] environment: CADDY_DOMAIN: ${CADDY_DOMAIN:-} ports: - "80:80" - "443:443" volumes: - ./packages/control-plane/docker/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config depends_on: app: condition: service_healthy restart: unless-stopped volumes: control-plane-data: object-store-data: caddy-data: caddy-config: