The queue shared the `pipeline:` prefix with flow state, so `RedisState.clear()` could DEL the queue stream and `keys()` enumerated queue entries — only callers filtering `__`-prefixed names kept it safe. It moves to `queue:` without a migration: whatever is in flight at the upgrade is dropped once, documented in DEPLOY.md rather than papered over. Alongside it: `pool_pre_ping`, so a connection idle across a Postgres restart costs a round trip instead of a failed request; the test suite pins ENVIRONMENT=local and DOMAIN=localhost itself rather than inheriting a deployment's .env; and `depth` leaves the queue stats, where it reported the capped journal length as if it were a backlog. ALERTS_FILE and PANELS_FILE now point at /data. They defaulted to a path on no volume, so alert routing and every wall-panel pairing were living in the container's writable layer and vanishing on each rebuild. Carrying the existing files across is a manual step; DEPLOY.md has it. development.md was still the upstream template — compose.override.yml, localhost.tiangolo.com, `docker compose watch` as the dev flow — and said nothing about the Playwright suite. Rewritten against what the Makefiles actually do. deployment.md was template text too, duplicating the root DEPLOY.md, and is gone.
309 lines
12 KiB
YAML
309 lines
12 KiB
YAML
# Production stack. Layered by the Makefile:
|
|
# compose.yml → production (Traefik + TLS, restart always)
|
|
# + compose.dev.yml → local dev (published ports, hot reload)
|
|
# + compose.local.yml → integrated stack on the shared `proxy` net
|
|
# Paths are relative to this directory, which compose uses as the project
|
|
# directory (build contexts therefore point at `..`, the repo root).
|
|
|
|
services:
|
|
|
|
db:
|
|
image: postgres:18
|
|
container_name: fluksio-db
|
|
restart: always
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
interval: 10s
|
|
retries: 5
|
|
start_period: 30s
|
|
timeout: 10s
|
|
volumes:
|
|
- app-db-data:/var/lib/postgresql/data/pgdata
|
|
env_file:
|
|
- ../.env
|
|
environment:
|
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
|
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
- POSTGRES_DB=${POSTGRES_DB?Variable not set}
|
|
|
|
redis:
|
|
image: redis:8.2-alpine
|
|
container_name: fluksio-redis
|
|
restart: always
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
# Flow state, not a cache: append-only persistence keeps the last value of
|
|
# every message across a restart.
|
|
command: redis-server --appendonly yes
|
|
# Deliberately not on `proxy` and not published: only the backend talks to it.
|
|
networks:
|
|
- default
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 10s
|
|
retries: 5
|
|
start_period: 10s
|
|
timeout: 5s
|
|
volumes:
|
|
- app-redis-data:/data
|
|
|
|
adminer:
|
|
image: adminer
|
|
container_name: fluksio-adminer
|
|
restart: always
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
# Deliberately not on `proxy`: the Postgres UI is reachable from the host
|
|
# in dev only (see compose.dev.yml), never routed from the internet.
|
|
networks:
|
|
- default
|
|
depends_on:
|
|
- db
|
|
environment:
|
|
- ADMINER_DESIGN=pepa-linha-dark
|
|
|
|
prestart:
|
|
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
|
container_name: fluksio-prestart
|
|
build:
|
|
context: ..
|
|
dockerfile: backend/Dockerfile
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
networks:
|
|
- proxy
|
|
- default
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
restart: true
|
|
command: bash scripts/prestart.sh
|
|
env_file:
|
|
- ../.env
|
|
environment:
|
|
- DOMAIN=${DOMAIN}
|
|
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
|
- ENVIRONMENT=${ENVIRONMENT}
|
|
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
|
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
|
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
|
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
|
- SMTP_HOST=${SMTP_HOST}
|
|
- SMTP_USER=${SMTP_USER}
|
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
|
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
|
- POSTGRES_SERVER=db
|
|
- POSTGRES_PORT=${POSTGRES_PORT}
|
|
- POSTGRES_DB=${POSTGRES_DB}
|
|
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
|
- SENTRY_DSN=${SENTRY_DSN}
|
|
|
|
backend:
|
|
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
|
container_name: fluksio-api
|
|
restart: always
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
networks:
|
|
- proxy
|
|
- default
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
restart: true
|
|
redis:
|
|
condition: service_healthy
|
|
restart: true
|
|
prestart:
|
|
condition: service_completed_successfully
|
|
env_file:
|
|
- ../.env
|
|
environment:
|
|
- DOMAIN=${DOMAIN}
|
|
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
|
- ENVIRONMENT=${ENVIRONMENT}
|
|
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
|
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
|
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
|
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
|
- SMTP_HOST=${SMTP_HOST}
|
|
- SMTP_USER=${SMTP_USER}
|
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
|
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
|
- POSTGRES_SERVER=db
|
|
- POSTGRES_PORT=${POSTGRES_PORT}
|
|
- POSTGRES_DB=${POSTGRES_DB}
|
|
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
|
- SENTRY_DSN=${SENTRY_DSN}
|
|
# Flow state survives a restart in Redis; without a host it stays in memory.
|
|
- REDIS_HOST=redis
|
|
# Flows are files in a git repository; secrets sit encrypted beside it.
|
|
- FLOWS_DIR=/data/flows
|
|
- SECRETS_FILE=/data/secrets.enc
|
|
# The MCP endpoint for agents, and the key its tokens are signed with.
|
|
# The key lives on the same volume as the flows, so it survives a rebuild
|
|
# and every issued token with it.
|
|
- MCP_ENABLED=${MCP_ENABLED-false}
|
|
- OAUTH_PRIVATE_KEY_FILE=/data/oauth-key.pem
|
|
# The portal enrolment, for the same reason: it holds this installation's
|
|
# credential and the key it pinned, so losing it on a rebuild would mean
|
|
# enrolling again by hand. Absent until someone connects a portal.
|
|
- CLOUD_CONFIG_FILE=/data/cloud.json
|
|
# Alert routing and the wall-panel pairings. Both are written at runtime,
|
|
# so off the volume they would land in the container's writable layer and
|
|
# be lost on the next rebuild — un-pairing every screen.
|
|
- ALERTS_FILE=/data/alerts.json
|
|
- PANELS_FILE=/data/panels.json
|
|
|
|
volumes:
|
|
- app-flow-data:/data
|
|
|
|
# Deep health: fails when the event loop is wedged or Redis is gone, not
|
|
# just when the process is dead. Autoheal restarts on unhealthy.
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health/"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 30s
|
|
|
|
build:
|
|
context: ..
|
|
dockerfile: backend/Dockerfile
|
|
labels:
|
|
- autoheal=true
|
|
- traefik.enable=true
|
|
- traefik.docker.network=proxy
|
|
- traefik.constraint-label=proxy
|
|
|
|
- traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000
|
|
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`)
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http
|
|
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`)
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le
|
|
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect
|
|
|
|
frontend:
|
|
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
|
|
container_name: fluksio-app
|
|
restart: always
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
networks:
|
|
- proxy
|
|
- default
|
|
build:
|
|
context: ..
|
|
dockerfile: frontend/Dockerfile
|
|
args:
|
|
- VITE_API_URL=https://api.${DOMAIN?Variable not set}
|
|
- NODE_ENV=production
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.docker.network=proxy
|
|
- traefik.constraint-label=proxy
|
|
|
|
- traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80
|
|
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`app.${DOMAIN?Variable not set}`)
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http
|
|
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`app.${DOMAIN?Variable not set}`)
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le
|
|
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect
|
|
|
|
# Docker never restarts a merely *unhealthy* container on its own; autoheal
|
|
# closes that gap for the services labeled autoheal=true.
|
|
#
|
|
# Behind a profile because it needs the Docker socket, which is host-wide
|
|
# authority: on a machine that runs anything besides this stack, that is a
|
|
# deliberate operator decision. `make up` opts in; the dev stacks do not.
|
|
autoheal:
|
|
image: willfarrell/autoheal:latest
|
|
container_name: fluksio-autoheal
|
|
profiles: ["autoheal"]
|
|
restart: always
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
networks:
|
|
- default
|
|
environment:
|
|
# Scoped by label, so it only ever restarts this stack's backend.
|
|
- AUTOHEAL_CONTAINER_LABEL=autoheal
|
|
- AUTOHEAL_INTERVAL=15
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
|
|
# ── Site analytics (optional) ──────────────────────────────────────
|
|
# Self-hosted, cookieless Umami on analytics.${DOMAIN}. Gated behind the
|
|
# `analytics` profile, so a plain `make up` never starts it; bring it up with
|
|
# `make umami`, which also provisions its database.
|
|
umami:
|
|
container_name: fluksio-umami
|
|
# Pinned by digest so a bump is a deliberate step: this is an internet-facing
|
|
# third-party dashboard, not a library.
|
|
image: ghcr.io/umami-software/umami:postgresql-latest@sha256:87312d334d009ee67ee0d2fba8fed01435547cc468e452243aef5133a9984d48
|
|
restart: always
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
profiles: ["analytics"]
|
|
networks:
|
|
- proxy
|
|
- default
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
# Deliberately no `env_file: ../.env` -- this third-party image receives the
|
|
# three variables it needs and none of the app's secrets.
|
|
environment:
|
|
- DATABASE_TYPE=postgresql
|
|
# Least-privilege role owning only the umami database, never the shared
|
|
# Postgres superuser (provisioned by `make umami`).
|
|
- DATABASE_URL=postgresql://${UMAMI_DB_USER:-umami}:${UMAMI_DB_PASSWORD}@db:5432/${UMAMI_DB:-umami}
|
|
- APP_SECRET=${UMAMI_APP_SECRET:-}
|
|
expose:
|
|
- "3000"
|
|
# No healthcheck on purpose: the upstream image ships neither curl nor wget,
|
|
# so a probe would leave the container permanently `starting` and Traefik
|
|
# would never route it (the same trap the backend's python healthcheck
|
|
# documents).
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.docker.network=proxy
|
|
- traefik.constraint-label=proxy
|
|
|
|
- traefik.http.services.${STACK_NAME?Variable not set}-umami.loadbalancer.server.port=3000
|
|
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-http.rule=Host(`analytics.${DOMAIN?Variable not set}`)
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-http.entrypoints=http
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-http.middlewares=https-redirect
|
|
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-https.rule=Host(`analytics.${DOMAIN?Variable not set}`)
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-https.entrypoints=https
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-https.tls=true
|
|
- traefik.http.routers.${STACK_NAME?Variable not set}-umami-https.tls.certresolver=le
|
|
|
|
volumes:
|
|
app-db-data:
|
|
app-redis-data:
|
|
app-flow-data:
|
|
|
|
networks:
|
|
# Shared with the website stack and whatever reverse proxy fronts them.
|
|
# Created once by the workspace root's scripts/setup.sh.
|
|
proxy:
|
|
external: true
|