Restructure docker into layered compose files, add a Makefile

Move compose.yml/compose.override.yml/compose.traefik.yml into docker/ and
split into the explicit prod -> dev -> local layering; compose.override.yml
had to be renamed because docker compose auto-loads that filename, which
defeats the layering.

- external network traefik-public -> proxy (shared with the website stack)
- frontend host dashboard.${DOMAIN} -> app.${DOMAIN}
- stable container_names, security_opt no-new-privileges on prod services
- adminer bound to 127.0.0.1 in dev instead of all interfaces
- .env.example replaces the committed .env
- pre-commit biome hook ran npm in a bun repo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-08-09 15:21:34 +02:00
co-authored by Claude Opus 5
parent 1916f7f778
commit 529b5f9ed5
6 changed files with 203 additions and 79 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ repos:
hooks: hooks:
- id: local-biome-check - id: local-biome-check
name: biome check name: biome check
entry: npm run lint entry: bun run lint
language: system language: system
types: [text] types: [text]
files: ^frontend/ files: ^frontend/
+92
View File
@@ -0,0 +1,92 @@
# ─── Fluksio app Makefile ───
# Convenience targets for development, testing, linting, and deployment.
# The workspace root delegates to these (see ../Makefile).
.PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \
generate-client test test-backend test-frontend lint lint-backend \
lint-frontend clean help
COMPOSE_ROOT := $(CURDIR)
# Explicit project name keeps this stack isolated from the sibling website
# stack (otherwise both default to "docker", the directory their compose
# files live in).
COMPOSE_PROJECT := fluksio-app
# Compose interpolation needs the project-local .env before reading compose.yml.
COMPOSE := docker compose -p $(COMPOSE_PROJECT) --env-file $(COMPOSE_ROOT)/.env
COMPOSE_PROD := $(COMPOSE) -f docker/compose.yml
COMPOSE_DEV := $(COMPOSE_PROD) -f docker/compose.dev.yml
# Integrated local stack: dev stack wired onto the shared `proxy` network.
COMPOSE_LOCAL := $(COMPOSE_DEV) -f docker/compose.local.yml
help: ## Show available targets
@awk 'BEGIN{FS=":.*?## "} /^[a-zA-Z_-]+:.*?##/ {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# ── Development (Docker) ──────────────────────────────────────────
dev-utils: ## Start only the utility containers (db, adminer, proxy, mailcatcher, prestart)
$(COMPOSE_DEV) up --build db adminer proxy mailcatcher prestart
dev: ## Start the full dev stack (includes a local Traefik proxy)
$(COMPOSE_DEV) up --build
dev-local: ## Start the integrated local stack (called by the root `make dev`)
DOMAIN=$${DOMAIN:-localhost} ENVIRONMENT=$${ENVIRONMENT:-local} \
$(COMPOSE_LOCAL) up --build -d proxy db adminer prestart backend frontend mailcatcher
up: ## Start the production stack
$(COMPOSE_PROD) up --build -d
update: ## Pull, rebuild using the layer cache, and recreate changed containers
git pull
$(COMPOSE_PROD) build
$(COMPOSE_PROD) up -d --remove-orphans
docker image prune -f
down: ## Stop all running containers
-$(COMPOSE_LOCAL) down
-$(COMPOSE_PROD) down
# ── Development (local, no Docker) ───────────────────────────────
# Run `make dev-backend` and `make dev-frontend` in two separate terminals.
install: ## Install all dependencies (backend + frontend)
cd backend && uv sync
cd frontend && bun install
dev-backend: ## Start the FastAPI backend with hot-reload (local)
cd backend && uv run fastapi dev app/main.py
dev-frontend: ## Start the Vite dev server (local)
cd frontend && bun dev
generate-client: ## Regenerate the frontend SDK from the backend's OpenAPI schema
bash scripts/generate-client.sh
# ── Testing ───────────────────────────────────────────────────────
test: test-backend test-frontend ## Run all tests (backend + frontend)
test-backend: ## Run backend tests (pytest + coverage)
cd backend && uv run bash scripts/tests-start.sh
test-frontend: ## Run frontend tests (Playwright e2e)
cd frontend && bunx playwright test
# ── Linting ───────────────────────────────────────────────────────
lint: lint-backend lint-frontend ## Run all linters
lint-backend: ## Lint backend with ruff + mypy
cd backend && uv run ruff check .
cd backend && uv run ruff format --check .
cd backend && uv run mypy app
lint-frontend: ## Lint frontend with biome
cd frontend && bun run lint
# ── Cleanup ───────────────────────────────────────────────────────
clean: ## Remove build artifacts and caches
rm -rf frontend/dist frontend/blob-report frontend/test-results
rm -rf backend/.pytest_cache backend/htmlcov
find backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+27 -39
View File
@@ -1,10 +1,12 @@
# Local development overrides: a self-contained stack with its own Traefik,
# published host ports, hot reload and the mail/browser test helpers.
# Never used in production — the Makefile only layers it for `make dev*`.
services: services:
# Local services are available on their ports, but also available on: # Local Traefik. Services stay reachable both on their published ports and
# http://api.localhost.tiangolo.com: backend # on http://api.localhost / http://app.localhost via the routers in
# http://dashboard.localhost.tiangolo.com: frontend # compose.yml. Dashboard at http://localhost:8090.
# etc. To enable it, update .env, set:
# DOMAIN=localhost.tiangolo.com
proxy: proxy:
image: traefik:3.6 image: traefik:3.6
volumes: volumes:
@@ -12,37 +14,25 @@ services:
ports: ports:
- "80:80" - "80:80"
- "8090:8080" - "8090:8080"
# Duplicate the command from compose.yml to add --api.insecure=true
command: command:
# Enable Docker in Traefik, so that it reads labels from Docker services
- --providers.docker - --providers.docker
# Add a constraint to only use services with the label for this stack - --providers.docker.constraints=Label(`traefik.constraint-label`, `proxy`)
- --providers.docker.constraints=Label(`traefik.constraint-label`, `traefik-public`)
# Do not expose all Docker services, only the ones explicitly exposed
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
# Create an entrypoint "http" listening on port 80
- --entrypoints.http.address=:80 - --entrypoints.http.address=:80
# Create an entrypoint "https" listening on port 443
- --entrypoints.https.address=:443 - --entrypoints.https.address=:443
# Enable the access log, with HTTP requests
- --accesslog - --accesslog
# Enable the Traefik log, for configurations and errors
- --log - --log
# Enable debug logging for local development
- --log.level=DEBUG - --log.level=DEBUG
# Enable the Dashboard and API
- --api - --api
# Enable the Dashboard and API in insecure mode for local development
- --api.insecure=true - --api.insecure=true
labels: labels:
# Enable Traefik for this service, to make it available in the public network
- traefik.enable=true - traefik.enable=true
- traefik.constraint-label=traefik-public - traefik.constraint-label=proxy
# Dummy https-redirect middleware that doesn't really redirect, only to # Dummy https-redirect middleware that doesn't really redirect, so the
# allow running it locally # production routers' middleware reference still resolves locally.
- traefik.http.middlewares.https-redirect.contenttype.autodetect=false - traefik.http.middlewares.https-redirect.contenttype.autodetect=false
networks: networks:
- traefik-public - proxy
- default - default
db: db:
@@ -53,16 +43,15 @@ services:
adminer: adminer:
restart: "no" restart: "no"
ports: ports:
- "8080:8080" - "127.0.0.1:8080:8080"
backend: backend:
restart: "no" restart: "no"
ports: ports:
- "8000:8000" - "8000:8000"
build: build:
context: . context: ..
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
# command: sleep infinity # Infinite loop to keep container alive doing nothing
command: command:
- fastapi - fastapi
- run - run
@@ -70,22 +59,21 @@ services:
- "app/main.py" - "app/main.py"
develop: develop:
watch: watch:
- path: ./backend - path: ../backend
action: sync action: sync
target: /app/backend target: /app/backend
ignore: ignore:
- ./backend/.venv - ../backend/.venv
- .venv - .venv
- path: ./backend/pyproject.toml - path: ../backend/pyproject.toml
action: rebuild action: rebuild
# TODO: remove once coverage is done locally
volumes: volumes:
- ./backend/htmlcov:/app/backend/htmlcov - ../backend/htmlcov:/app/backend/htmlcov
environment: environment:
SMTP_HOST: "mailcatcher" SMTP_HOST: "mailcatcher"
SMTP_PORT: "1025" SMTP_PORT: "1025"
SMTP_TLS: "false" SMTP_TLS: "false"
EMAILS_FROM_EMAIL: "noreply@example.com" EMAILS_FROM_EMAIL: "noreply@fluksio.com"
mailcatcher: mailcatcher:
image: schickling/mailcatcher image: schickling/mailcatcher
@@ -98,7 +86,7 @@ services:
ports: ports:
- "5173:80" - "5173:80"
build: build:
context: . context: ..
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
args: args:
- VITE_API_URL=http://localhost:8000 - VITE_API_URL=http://localhost:8000
@@ -106,7 +94,7 @@ services:
playwright: playwright:
build: build:
context: . context: ..
dockerfile: frontend/Dockerfile.playwright dockerfile: frontend/Dockerfile.playwright
args: args:
- VITE_API_URL=http://backend:8000 - VITE_API_URL=http://backend:8000
@@ -116,20 +104,20 @@ services:
- backend - backend
- mailcatcher - mailcatcher
env_file: env_file:
- .env - ../.env
environment: environment:
- VITE_API_URL=http://backend:8000 - VITE_API_URL=http://backend:8000
- MAILCATCHER_HOST=http://mailcatcher:1080 - MAILCATCHER_HOST=http://mailcatcher:1080
# For the reports when run locally
- PLAYWRIGHT_HTML_HOST=0.0.0.0 - PLAYWRIGHT_HTML_HOST=0.0.0.0
- CI=${CI} - CI=${CI}
volumes: volumes:
- ./frontend/blob-report:/app/frontend/blob-report - ../frontend/blob-report:/app/frontend/blob-report
- ./frontend/test-results:/app/frontend/test-results - ../frontend/test-results:/app/frontend/test-results
ports: ports:
- 9323:9323 - 9323:9323
networks: networks:
traefik-public: # Self-contained dev: create a stack-local network instead of expecting the
# For local dev, don't expect an external Traefik network # shared one. compose.local.yml flips this back for the integrated stack.
proxy:
external: false external: false
+33
View File
@@ -0,0 +1,33 @@
# Integrated local stack — layered on top of compose.yml + compose.dev.yml by
# the root `make dev`. Reattaches this stack's Traefik to the shared external
# `proxy` network so it also routes the website stack's containers, and serves
# everything over plain http on *.${DOMAIN} (default *.localhost).
services:
proxy:
networks:
- proxy
- default
backend:
environment:
- ENVIRONMENT=local
- FRONTEND_HOST=http://app.${DOMAIN:-localhost}
- BACKEND_CORS_ORIGINS=http://${DOMAIN:-localhost},http://app.${DOMAIN:-localhost}
prestart:
environment:
- ENVIRONMENT=local
- FRONTEND_HOST=http://app.${DOMAIN:-localhost}
frontend:
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:
external: true
@@ -10,8 +10,8 @@ services:
labels: labels:
# Enable Traefik for this service, to make it available in the public network # Enable Traefik for this service, to make it available in the public network
- traefik.enable=true - traefik.enable=true
# Use the traefik-public network (declared below) # Use the proxy network (declared below)
- traefik.docker.network=traefik-public - traefik.docker.network=proxy
# Define the port inside of the Docker service to use # Define the port inside of the Docker service to use
- traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080 - traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080
# Make Traefik use this domain (from an environment variable) in HTTP # Make Traefik use this domain (from an environment variable) in HTTP
@@ -39,7 +39,7 @@ services:
# Add Docker as a mounted volume, so that Traefik can read the labels of other services # Add Docker as a mounted volume, so that Traefik can read the labels of other services
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
# Mount the volume to store the certificates # Mount the volume to store the certificates
- traefik-public-certificates:/certificates - traefik-certificates:/certificates
command: command:
# Enable Docker in Traefik, so that it reads labels from Docker services # Enable Docker in Traefik, so that it reads labels from Docker services
- --providers.docker - --providers.docker
@@ -64,14 +64,14 @@ services:
networks: networks:
# Use the public network created to be shared between Traefik and # Use the public network created to be shared between Traefik and
# any other service that needs to be publicly available with HTTPS # any other service that needs to be publicly available with HTTPS
- traefik-public - proxy
volumes: volumes:
# Create a volume to store the certificates, even if the container is recreated # Create a volume to store the certificates, even if the container is recreated
traefik-public-certificates: traefik-certificates:
networks: networks:
# Use the previously created public network "traefik-public", shared with other # Use the previously created public network "proxy", shared with other
# services that need to be publicly available via this Traefik # services that need to be publicly available via this Traefik
traefik-public: proxy:
external: true external: true
+43 -32
View File
@@ -1,8 +1,18 @@
# 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: services:
db: db:
image: postgres:18 image: postgres:18
container_name: fluksio-db
restart: always restart: always
security_opt:
- no-new-privileges:true
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
@@ -12,7 +22,7 @@ services:
volumes: volumes:
- app-db-data:/var/lib/postgresql/data/pgdata - app-db-data:/var/lib/postgresql/data/pgdata
env_file: env_file:
- .env - ../.env
environment: environment:
- PGDATA=/var/lib/postgresql/data/pgdata - PGDATA=/var/lib/postgresql/data/pgdata
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
@@ -21,34 +31,29 @@ services:
adminer: adminer:
image: adminer image: adminer
container_name: fluksio-adminer
restart: always 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: networks:
- traefik-public
- default - default
depends_on: depends_on:
- db - db
environment: environment:
- ADMINER_DESIGN=pepa-linha-dark - ADMINER_DESIGN=pepa-linha-dark
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
- traefik.constraint-label=traefik-public
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.rule=Host(`adminer.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.entrypoints=http
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.middlewares=https-redirect
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.rule=Host(`adminer.${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.entrypoints=https
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le
- traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080
prestart: prestart:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
container_name: fluksio-prestart
build: build:
context: . context: ..
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
security_opt:
- no-new-privileges:true
networks: networks:
- traefik-public - proxy
- default - default
depends_on: depends_on:
db: db:
@@ -56,7 +61,7 @@ services:
restart: true restart: true
command: bash scripts/prestart.sh command: bash scripts/prestart.sh
env_file: env_file:
- .env - ../.env
environment: environment:
- DOMAIN=${DOMAIN} - DOMAIN=${DOMAIN}
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
@@ -78,9 +83,12 @@ services:
backend: backend:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
container_name: fluksio-api
restart: always restart: always
security_opt:
- no-new-privileges:true
networks: networks:
- traefik-public - proxy
- default - default
depends_on: depends_on:
db: db:
@@ -89,7 +97,7 @@ services:
prestart: prestart:
condition: service_completed_successfully condition: service_completed_successfully
env_file: env_file:
- .env - ../.env
environment: environment:
- DOMAIN=${DOMAIN} - DOMAIN=${DOMAIN}
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
@@ -116,12 +124,12 @@ services:
retries: 5 retries: 5
build: build:
context: . context: ..
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=traefik-public - traefik.docker.network=proxy
- traefik.constraint-label=traefik-public - traefik.constraint-label=proxy
- traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000 - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000
@@ -133,42 +141,45 @@ services:
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true - 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-https.tls.certresolver=le
# Enable redirection for HTTP and HTTPS
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect
frontend: frontend:
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
container_name: fluksio-app
restart: always restart: always
security_opt:
- no-new-privileges:true
networks: networks:
- traefik-public - proxy
- default - default
build: build:
context: . context: ..
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
args: args:
- VITE_API_URL=https://api.${DOMAIN?Variable not set} - VITE_API_URL=https://api.${DOMAIN?Variable not set}
- NODE_ENV=production - NODE_ENV=production
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=traefik-public - traefik.docker.network=proxy
- traefik.constraint-label=traefik-public - traefik.constraint-label=proxy
- traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80 - 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(`dashboard.${DOMAIN?Variable not set}`) - 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-http.entrypoints=http
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`) - 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.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=true
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le
# Enable redirection for HTTP and HTTPS
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect
volumes: volumes:
app-db-data: app-db-data:
networks: networks:
traefik-public: # Shared with the website stack and whatever reverse proxy fronts them.
# Allow setting it to false for testing # Created once by the workspace root's scripts/setup.sh.
proxy:
external: true external: true