One image for the whole app: engine, API and dashboard on one port

The backend image now builds the SPA and serves it on the API's own origin
(fluksio/spa.py), so an instance is a single container with no proxy, DNS,
port 80 or .env: `docker compose up -d` with the new compose.yml runs it on
localhost:8080. It starts on fluksio serve (persisted secret key, first
admin printed once) and brings its own Redis when REDIS_HOST is unset.

Slimmer: python:3.13-slim, no dev group, uv once, fastapi without the cloud
CLI. Optional features are per-feature extras (mqtt, influx, email, sentry,
ml, server for all) picked with the EXTRAS build argument; numpy moved to
`ml`. ffmpeg left the image for imageio-ffmpeg via the Modules screen.

The operator stack runs the same image with app. and api. routed to it; the
nginx frontend service, the LAN nginx proxy and DOMAIN in the backend are
gone, and the MCP issuer is FRONTEND_HOST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 11:40:02 +02:00
co-authored by Claude Opus 5.5
parent 29aca91978
commit 48aac3855b
39 changed files with 573 additions and 728 deletions
+15 -67
View File
@@ -1,4 +1,5 @@
# Production stack. Layered by the Makefile:
# 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
@@ -75,13 +76,6 @@ services:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
container_name: fluksio-api
restart: always
# PID 1 that reaps orphans. A python node's worker processes outlive the
# process holding their handle whenever that one goes without stopping the
# pool -- which `--reload` does on every source edit -- and reparent onto
# PID 1, which is the app itself and waits for nobody else's children. The
# container filled up with zombie `python`. Declared here rather than in
# compose.dev.yml because an init closes the whole class, not just reload.
init: true
security_opt:
- no-new-privileges:true
networks:
@@ -94,14 +88,13 @@ services:
env_file:
- ../.env
environment:
- DOMAIN=${DOMAIN}
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
- ENVIRONMENT=${ENVIRONMENT}
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
# Whose X-Forwarded-For is believed: Traefik's and the frontend's nginx,
# which reach this from the compose networks (Docker's default pools sit
# in 172.16.0.0/12). Both write the header themselves, so the hop they
# add is the caller. No port is published here, so nothing else can.
# 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}
@@ -111,7 +104,8 @@ services:
- SMTP_PASSWORD=${SMTP_PASSWORD}
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
- SENTRY_DSN=${SENTRY_DSN}
# Flow state survives a restart in Redis; without a host it stays in memory.
# 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
@@ -151,21 +145,15 @@ services:
# together (ARTIFACT_VOLATILE_BYTES) for more cameras or bigger frames.
shm_size: 128mb
# 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
# 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:
# Opt-in: ffmpeg costs the image about 400 MB (backend/Dockerfile).
- WITH_FFMPEG=${WITH_FFMPEG:-false}
# Every optional feature by default; EXTRAS=mqtt,influx in .env trims it.
- EXTRAS=${EXTRAS-server}
labels:
- autoheal=true
- traefik.enable=true
@@ -174,56 +162,16 @@ services:
- 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.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(`api.${DOMAIN?Variable not set}`)
- 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
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:
# The address baked into the bundle. `scripts/setup.sh` already derives
# it from ENVIRONMENT and writes it to .env — http for a local target,
# because nothing terminates TLS there — so take it from the environment
# and keep the https form only as the default for a checkout that
# predates that key. Hardcoding https here made every `up --build` that
# did not also layer compose.local.yml ship a bundle calling
# https://api.localhost, which fails the certificate check and with it
# the whole login.
- VITE_API_URL=${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
# Static documentation site (zensical), served at docs.${DOMAIN}. Content and
# config live in ../docs/ and ../zensical.toml; `make docs-serve` previews it.
docs: