name: openbooks x-openbooks-runtime-environment: &openbooks-runtime-environment NODE_ENV: production HOSTNAME: 0.0.0.0 PORT: "3000" OPENBOOKS_DB_URL: postgres://openbooks_app:${OPENBOOKS_DB_PASSWORD:?OPENBOOKS_DB_PASSWORD is required}@postgres:5432/openbooks # Cross-tenant work (installation-wide maintenance, sandbox promotion) runs # on this dedicated BYPASSRLS login, never on the runtime login: production # web/worker refuse at import without it (engine/src/platform/db.ts). The # one-shot bootstrap provisions the role automatically (see # docs/operations/communal-postgres.md for the posture it enforces); # password rotation is a re-run of the bootstrap service with the new value. OPENBOOKS_BYPASS_DB_URL: postgres://openbooks_bypass:${OPENBOOKS_BYPASS_DB_PASSWORD:?OPENBOOKS_BYPASS_DB_PASSWORD is required}@postgres:5432/openbooks OPENBOOKS_REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@redis:6379 SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required} OPENBOOKS_DATA_KEY: ${OPENBOOKS_DATA_KEY:?OPENBOOKS_DATA_KEY is required} OPENBOOKS_INTERNAL_TOKEN: ${OPENBOOKS_INTERNAL_TOKEN:?OPENBOOKS_INTERNAL_TOKEN is required} OPENBOOKS_APP_URL: ${OPENBOOKS_APP_URL:-http://localhost:4780} APP_BASE_URL: ${OPENBOOKS_APP_URL:-http://localhost:4780} # OpenTelemetry (optional, see docs/operations/observability.md): set # an OTLP/HTTP collector endpoint and the web + worker processes export # traces/metrics; leaving all endpoints empty keeps them a free no-op. OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:-} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-openbooks} OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-} OTEL_METRIC_EXPORT_INTERVAL: ${OTEL_METRIC_EXPORT_INTERVAL:-} OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-} OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-} # Keep proxy-derived client addresses disabled unless every inbound request # crosses a trusted proxy that overwrites forwarding headers. OPENBOOKS_TRUST_PROXY: ${OPENBOOKS_TRUST_PROXY:-0} OPENBOOKS_OIDC_ISSUER: ${OPENBOOKS_OIDC_ISSUER:-} OPENBOOKS_OIDC_CLIENT_ID: ${OPENBOOKS_OIDC_CLIENT_ID:-} OPENBOOKS_OIDC_CLIENT_SECRET: ${OPENBOOKS_OIDC_CLIENT_SECRET:-} OPENBOOKS_OIDC_LABEL: ${OPENBOOKS_OIDC_LABEL:-} S3_ENDPOINT: http://minio:9000 S3_REGION: us-east-1 # Least privilege: the application holds a dedicated MinIO user scoped to # the app bucket (created by minio-init below), never the root credentials. S3_ACCESS_KEY_ID: ${MINIO_APP_USER:-openbooks-app} S3_SECRET_ACCESS_KEY: ${MINIO_APP_PASSWORD:?MINIO_APP_PASSWORD is required} S3_BUCKET: openbooks services: postgres: # Exact release tags make upgrades operator-controlled and reproducible. image: postgres:16.9-alpine3.22@sha256:7c688148e5e156d0e86df7ba8ae5a05a2386aaec1e2ad8e6d11bdf10504b1fb7 environment: POSTGRES_DB: openbooks POSTGRES_USER: openbooks_owner POSTGRES_PASSWORD: ${POSTGRES_OWNER_PASSWORD:?POSTGRES_OWNER_PASSWORD is required} volumes: - postgres-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U openbooks_owner -d openbooks"] interval: 5s timeout: 5s retries: 20 restart: unless-stopped redis: image: redis:7.4.5-alpine3.21@sha256:bb186d083732f669da90be8b0f975a37812b15e913465bb14d845db72a4e3e08 command: - redis-server - --appendonly - "yes" - --requirepass - ${REDIS_PASSWORD} volumes: - redis-data:/data healthcheck: test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] interval: 5s timeout: 5s retries: 20 environment: REDIS_PASSWORD: ${REDIS_PASSWORD} restart: unless-stopped minio: image: minio/minio:RELEASE.2025-04-22T22-12-26Z@sha256:a1ea29fa28355559ef137d71fc570e508a214ec84ff8083e39bc5428980b015e command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} volumes: - minio-data:/data healthcheck: # curl left the minio image in late 2023 (ubi-micro base; see # minio/minio#18372), so a curl probe can never pass and minio-init — # gated on service_healthy — would never run. `mc` ships inside the # server image for exactly this: this is MinIO's documented probe. test: ["CMD", "mc", "ready", "local"] interval: 5s timeout: 5s retries: 20 restart: unless-stopped minio-init: image: minio/mc:RELEASE.2025-04-16T18-13-26Z@sha256:aead63c77f9db9107f1696fb08ecb0faeda23729cde94b0f663edf4fe09728e3 depends_on: minio: condition: service_healthy environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_APP_USER: ${MINIO_APP_USER:-openbooks-app} MINIO_APP_PASSWORD: ${MINIO_APP_PASSWORD:?MINIO_APP_PASSWORD is required} entrypoint: - /bin/sh - -ec - | mc alias set local http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" mc mb --ignore-existing local/openbooks # Dedicated least-privilege app identity: web/worker get these # credentials (never root) with a custom policy scoped to the app # bucket only. Every step is idempotent so re-running this one-shot # is safe; `policy set` re-attaching is a no-op success. # Rotation: the password is only applied at creation. To rotate, # remove the user and re-run this service: # docker compose exec minio mc admin user remove local "$APP_USER" # docker compose up minio-init --force-recreate APP_USER="$${MINIO_APP_USER:-openbooks-app}" mc admin user info local "$APP_USER" >/dev/null 2>&1 \ || mc admin user add local "$APP_USER" "$${MINIO_APP_PASSWORD}" POLICY_FILE="$(mktemp)" cat > "$POLICY_FILE" <<'POLICY_JSON' { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": [ "s3:ListBucket", "s3:GetBucketLocation", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts", "s3:ListBucketMultipartUploads" ], "Resource": ["arn:aws:s3:::openbooks", "arn:aws:s3:::openbooks/*"] }] } POLICY_JSON mc admin policy info local openbooks-app >/dev/null 2>&1 \ || mc admin policy create local openbooks-app "$POLICY_FILE" rm -f "$POLICY_FILE" mc admin policy set local openbooks-app "user=$APP_USER" restart: "no" bootstrap: image: ${OPENBOOKS_IMAGE:?OPENBOOKS_IMAGE must be a post-clean, scanned image pinned by digest} pull_policy: missing command: ["node", "scripts/bootstrap.mjs"] environment: <<: *openbooks-runtime-environment OPENBOOKS_BOOTSTRAP: "1" # Changes with every immutable deployment so Compose recreates this # completed one-shot container and applies newly published migrations. OPENBOOKS_BOOTSTRAP_REV: ${OPENBOOKS_BOOTSTRAP_REV:-local} OPENBOOKS_MIGRATION_DB_URL: postgres://openbooks_owner:${POSTGRES_OWNER_PASSWORD:?POSTGRES_OWNER_PASSWORD is required}@postgres:5432/openbooks OPENBOOKS_RUNTIME_DB_URL: postgres://openbooks_app:${OPENBOOKS_DB_PASSWORD:?OPENBOOKS_DB_PASSWORD is required}@postgres:5432/openbooks ORG_NAME: ${ORG_NAME:-OpenBooks} ORG_CURRENCY: ${ORG_CURRENCY:?ORG_CURRENCY is required} ORG_COUNTRY: ${ORG_COUNTRY:?ORG_COUNTRY is required} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@openbooks.local} ADMIN_NAME: ${ADMIN_NAME:-Administrator} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} depends_on: postgres: condition: service_healthy restart: "no" web: image: ${OPENBOOKS_IMAGE:?OPENBOOKS_IMAGE must be a post-clean, scanned image pinned by digest} pull_policy: missing command: ["node", "web/server.js"] environment: <<: *openbooks-runtime-environment depends_on: bootstrap: condition: service_completed_successfully redis: condition: service_healthy minio-init: condition: service_completed_successfully ports: - "${OPENBOOKS_BIND_ADDRESS:-127.0.0.1}:${OPENBOOKS_PORT:-4780}:3000" healthcheck: test: - CMD - node - -e - "fetch('http://127.0.0.1:3000/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" interval: 10s timeout: 5s retries: 20 start_period: 90s restart: unless-stopped # Scheduled work runs HERE: the worker owns every scheduled tick (scripts, # feeds, billing, outbox, flows, close scans), claimed once across replicas # by the shared Postgres lock. The web service intentionally sets no # OPENBOOKS_RUN_SCHEDULER — set it to 1 on web only for single-process # installs; developer processes (`next dev`) never schedule. worker: image: ${OPENBOOKS_IMAGE:?OPENBOOKS_IMAGE must be a post-clean, scanned image pinned by digest} pull_policy: missing command: ["node", "scripts/worker.mjs"] # Graceful-drain ceiling sized to the longest job's window (migration # steps hold 5-minute BullMQ locks; close-delivery renders and backups # run for minutes on large installs). SIGTERM starts the drain in # engine/src/worker/index.ts — workers close first while Redis is alive, # then connections — so an ordinary stop exits well before this ceiling; # only a hung job waits it out before SIGKILL, and stalled locks recover # via lockDuration/stalledInterval. Mirror this on the worker service in # the Dokploy-managed swarm stack file, which is not checked in here. stop_grace_period: 5m environment: <<: *openbooks-runtime-environment OPENBOOKS_INTERNAL_URL: http://web:3000 depends_on: web: condition: service_healthy restart: unless-stopped volumes: postgres-data: redis-data: minio-data: