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.
This commit is contained in:
+4
-19
@@ -42,20 +42,6 @@ services:
|
||||
volumes: !override
|
||||
- app-flow-data:/data
|
||||
|
||||
frontend:
|
||||
container_name: !reset null
|
||||
ports: !reset []
|
||||
# Baked at build time. compose.dev.yml bakes http://localhost:8000, which
|
||||
# resolves to the playwright container itself; the browser has to reach the
|
||||
# API by service name on the project network instead.
|
||||
build:
|
||||
args:
|
||||
- VITE_API_URL=http://backend:8000
|
||||
# Image tags are daemon-global too, and this one no longer holds the same
|
||||
# bytes as the smoke job's: without a per-job tag the two jobs overwrite
|
||||
# each other's `fluksio-frontend:latest` and the SPA calls the wrong API.
|
||||
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${COMPOSE_PROJECT_NAME:-ci}'
|
||||
|
||||
playwright:
|
||||
ports: !reset []
|
||||
# Same reason as backend above. These two exist so a local run leaves its
|
||||
@@ -64,9 +50,8 @@ services:
|
||||
# found with the provided path: frontend/blob-report" was. The job copies
|
||||
# the report out of the container instead.
|
||||
volumes: !reset []
|
||||
# frontend/playwright.config.ts defaults baseURL to http://app.localhost,
|
||||
# which is right for a local run against the integrated stack but resolves
|
||||
# to 127.0.0.1 in here. In CI the nginx `frontend` service serves the SPA.
|
||||
# This origin must stay in BACKEND_CORS_ORIGINS (.env.example).
|
||||
# frontend/playwright.config.ts defaults baseURL to the test stack's
|
||||
# hostname, which resolves to nothing in here. The backend serves the SPA
|
||||
# and its API on one origin.
|
||||
environment:
|
||||
- PLAYWRIGHT_BASE_URL=http://frontend
|
||||
- PLAYWRIGHT_BASE_URL=http://backend:8000
|
||||
|
||||
@@ -147,17 +147,6 @@ services:
|
||||
- "1080:1080"
|
||||
- "1025:1025"
|
||||
|
||||
frontend:
|
||||
restart: "no"
|
||||
ports:
|
||||
- "5173:80"
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: frontend/Dockerfile
|
||||
args:
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
- NODE_ENV=development
|
||||
|
||||
# Reachable at http://docs.${DOMAIN}, but a container rebuild per edit is a
|
||||
# poor authoring loop — use `make docs-serve` for that.
|
||||
docs:
|
||||
|
||||
+4
-15
@@ -2,26 +2,15 @@
|
||||
#
|
||||
# For reaching the app from a machine that cannot be given a DNS record or a
|
||||
# hosts entry. Traefik routes on the Host header and a bare IP matches no
|
||||
# router, so the frontend is published on a host port instead and proxies the
|
||||
# API and the hooks on that same port: http://<host-ip>:${APP_PORT} is the
|
||||
# whole app.
|
||||
# router, so the app container is published on a host port as well:
|
||||
# http://<host-ip>:${APP_PORT} is the whole app, dashboard, API and hooks.
|
||||
services:
|
||||
|
||||
backend:
|
||||
ports: !override
|
||||
- "${APP_PORT:-8080}:8000"
|
||||
environment:
|
||||
# The address the instance gives out: the link the Panels dialog shows a
|
||||
# screen and the one its emails carry. app.${DOMAIN} resolves on this host
|
||||
# only, so `make dev-lan` passes the published port on the LAN address.
|
||||
- FRONTEND_HOST=${LAN_URL:-http://app.${DOMAIN:-localhost}}
|
||||
|
||||
frontend:
|
||||
ports: !override
|
||||
- "${APP_PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./nginx-api-proxy.conf:/etc/nginx/extra-conf.d/backend-not-found.conf:ro
|
||||
build:
|
||||
args:
|
||||
# Empty on purpose: the client then addresses the API as a path on
|
||||
# whichever origin served the page, so one build works through
|
||||
# app.${DOMAIN}, the published port, and an ssh tunnel alike.
|
||||
- VITE_API_URL=
|
||||
|
||||
@@ -33,13 +33,6 @@ services:
|
||||
# talks to this backend rather than running one of its own.
|
||||
- BACKEND_CORS_ORIGINS=http://${DOMAIN:-localhost},http://app.${DOMAIN:-localhost},http://localhost:5173
|
||||
|
||||
frontend:
|
||||
ports: !reset []
|
||||
build:
|
||||
args:
|
||||
- VITE_API_URL=http://api.${DOMAIN:-localhost}
|
||||
- NODE_ENV=development
|
||||
|
||||
networks:
|
||||
# Shared with the website stack; created by the root scripts/setup.sh.
|
||||
proxy:
|
||||
|
||||
@@ -18,6 +18,3 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
frontend:
|
||||
container_name: !reset null
|
||||
|
||||
+15
-67
@@ -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:
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Serves the API from the same origin as the SPA, replacing the 404 stub the
|
||||
# image ships. Traefik routes on the Host header, which a bare IP never
|
||||
# matches, so this is what makes http://<host-ip>:<port> a complete app: the
|
||||
# client asks for /api/v1/... on whatever origin served it, and never needs a
|
||||
# resolvable api.${DOMAIN} or a CORS allowance.
|
||||
|
||||
# /hooks is where the flows' webhooks and served sockets live, next to the API
|
||||
# on the backend. One regex takes both, and a regex outranks `location /`,
|
||||
# which would otherwise answer /hooks with the SPA.
|
||||
location ~ ^/(api|hooks) {
|
||||
# Docker's embedded DNS, re-resolved: nginx otherwise pins the backend's
|
||||
# address at startup and 502s for good once that container is recreated.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
# The container name, which only the dev API carries. The test stack's
|
||||
# backend answers to the service name `backend` on the shared proxy
|
||||
# network too.
|
||||
set $backend_upstream http://fluksio-api:8000;
|
||||
proxy_pass $backend_upstream$request_uri;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
# Replaced, not appended to: whatever the client sent would otherwise sit
|
||||
# in front of the one hop the backend believes, and anything reading the
|
||||
# header naively would take it for the caller.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# The flow websocket (/api/v1/flows/ws) and a served socket upgrade through
|
||||
# here; passing the client's own Connection header covers both and
|
||||
# ordinary requests.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
# A live socket outlives the 60s default by design.
|
||||
proxy_read_timeout 1d;
|
||||
# nginx's 1 MB default would 413 any larger artifact upload; the backend
|
||||
# enforces MAX_ARTIFACT_BYTES itself, so the body streams through as is.
|
||||
client_max_body_size 0;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# Unchanged from the stub: the API docs stay off the frontend's origin.
|
||||
location /docs {
|
||||
return 404;
|
||||
}
|
||||
location /redoc {
|
||||
return 404;
|
||||
}
|
||||
Reference in New Issue
Block a user