# Production stack: one image (backend/Dockerfile) serves the engine, the API # and the dashboard on port 8000. 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). # Every container's log rotates: json-file, at most 3 files of 10 MB each. # Docker's default keeps one file that grows until the host's disk is full. x-logging: &logging driver: json-file options: max-size: "10m" max-file: "3" services: # Umami's database, and nothing else's. The engine keeps its own state in # SQLite on the flow-data volume, which is why this is behind a profile now. db: logging: *logging image: postgres:18 container_name: fluksio-db restart: always profiles: ["analytics"] security_opt: - no-new-privileges:true healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-app}"] 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 # Not required of the stack at large any more: only the analytics # profile starts this, and the engine has no Postgres to configure. - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_DB=${POSTGRES_DB:-app} redis: logging: *logging 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: logging: *logging image: adminer container_name: fluksio-adminer restart: always profiles: ["analytics"] 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 backend: logging: *logging 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: redis: condition: service_healthy restart: true env_file: - ../.env environment: - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - ENVIRONMENT=${ENVIRONMENT} - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} # Whose X-Forwarded-For is believed: the reverse proxy's, which reaches # this from the compose networks (Docker's default pools sit in # 172.16.0.0/12) and writes the header itself, so the hop it adds is the # caller. No port is published here, so nothing else can. - FORWARDED_ALLOW_IPS=${FORWARDED_ALLOW_IPS:-127.0.0.1,172.16.0.0/12} - 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} - SENTRY_DSN=${SENTRY_DSN} # This stack's own Redis rather than the one bundled in the image, so the # app-redis-data volume keeps holding the flow state. - REDIS_HOST=redis # Everything this instance keeps — the database, the flows, secrets, # artifacts and the user venv — on one volume. The paths below derive # from it; they are spelled out because each is load bearing. - DATA_DIR=/data # 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 instance'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 device nonces. 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. PANELS_FILE is # read once, by an instance from before devices, and renamed `.migrated`. - ALERTS_FILE=/data/alerts.json - PANELS_FILE=/data/panels.json - DEVICES_FILE=/data/devices.json # The push keypair and the browsers subscribed to it. Off the volume, a # rebuild would silently stop every phone being notified: the key they # subscribed against would be gone. - WEBPUSH_FILE=/data/webpush.json # Cron schedules use this timezone. Set it to the timezone your flows # should follow; the image defaults to UTC. - TZ=${TZ:-UTC} volumes: - app-flow-data:/data # `/dev/shm`, where frames a flow only shows live are held. Docker's # default is 64 MB, which the ring's own bound sits just under; raise both # together (ARTIFACT_VOLATILE_BYTES) for more cameras or bigger frames. shm_size: 128mb # The image's deep health check fails when the event loop is wedged or # Redis is gone, not just when the process is dead. Autoheal restarts on # unhealthy. build: context: .. dockerfile: backend/Dockerfile args: # Every optional feature by default; EXTRAS=mqtt,influx in .env trims it. - EXTRAS=${EXTRAS-server} 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(`app.${DOMAIN?Variable not set}`) || 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(`app.${DOMAIN?Variable not set}`) || 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 # Static documentation site (zensical), served at docs.${DOMAIN}. Content and # config live in ../docs/ and ../zensical.toml; `make docs-serve` previews it. docs: logging: *logging container_name: fluksio-docs restart: always security_opt: - no-new-privileges:true build: context: .. dockerfile: docker/Dockerfile.docs expose: - "80" networks: - proxy labels: - traefik.enable=true - traefik.docker.network=proxy - traefik.constraint-label=proxy - traefik.http.services.${STACK_NAME?Variable not set}-docs.loadbalancer.server.port=80 - traefik.http.routers.${STACK_NAME?Variable not set}-docs-http.rule=Host(`docs.${DOMAIN?Variable not set}`) - traefik.http.routers.${STACK_NAME?Variable not set}-docs-http.entrypoints=http - traefik.http.routers.${STACK_NAME?Variable not set}-docs-http.middlewares=https-redirect - traefik.http.routers.${STACK_NAME?Variable not set}-docs-https.rule=Host(`docs.${DOMAIN?Variable not set}`) - traefik.http.routers.${STACK_NAME?Variable not set}-docs-https.entrypoints=https - traefik.http.routers.${STACK_NAME?Variable not set}-docs-https.tls=true - traefik.http.routers.${STACK_NAME?Variable not set}-docs-https.tls.certresolver=le # 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: logging: *logging 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 # ── TURN relay for live view (optional) ──────────────────────────── # coturn, for a camera's live view between a viewer and a device that # cannot reach each other directly. It serves STUN too. Behind the `turn` # profile, so a plain `make up` never starts it. On the host's network, # because a relay port range through Docker's port mapping is a proxy # process per port. Credentials are minted by the backend from the same # TURN_SECRET (coturn's REST scheme), so nothing is stored here. The secret # goes in through a config file rather than the command line, which `ps` # shows. Configure ports with TURN_MIN_PORT and TURN_MAX_PORT and mount # certificates under TURN_CERT_DIR below. coturn: logging: *logging image: coturn/coturn:4.6.3 container_name: fluksio-coturn profiles: ["turn"] restart: always network_mode: host security_opt: - no-new-privileges:true environment: # Not `?`: compose reads every service, so a stack without the # profile would refuse to start. Checked when coturn starts instead. - TURN_SECRET=${TURN_SECRET:-} - TURN_REALM=turn.${DOMAIN?Variable not set} - TURN_MIN_PORT=${TURN_MIN_PORT:-49160} - TURN_MAX_PORT=${TURN_MAX_PORT:-49200} volumes: # fullchain.pem and privkey.pem for turn.${DOMAIN}. Without them coturn # runs without TLS on 5349. - ${TURN_CERT_DIR:-./turn-certs}:/certs:ro entrypoint: ["sh", "-c"] command: - | [ -n "$$TURN_SECRET" ] || { echo "TURN_SECRET is empty" >&2; exit 1; } cat > /tmp/turnserver.conf <