# 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 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