From 529b5f9ed5f0ae337b515f772d0550fa66bb0c7f Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Sun, 9 Aug 2026 15:21:34 +0200 Subject: [PATCH] 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) --- .pre-commit-config.yaml | 2 +- Makefile | 92 +++++++++++++++++++ .../compose.dev.yml | 66 ++++++------- docker/compose.local.yml | 33 +++++++ .../compose.traefik.yml | 14 +-- compose.yml => docker/compose.yml | 75 ++++++++------- 6 files changed, 203 insertions(+), 79 deletions(-) create mode 100644 Makefile rename compose.override.yml => docker/compose.dev.yml (52%) create mode 100644 docker/compose.local.yml rename compose.traefik.yml => docker/compose.traefik.yml (92%) rename compose.yml => docker/compose.yml (75%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc718ea..29a75eb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: hooks: - id: local-biome-check name: biome check - entry: npm run lint + entry: bun run lint language: system types: [text] files: ^frontend/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b7316bb --- /dev/null +++ b/Makefile @@ -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 diff --git a/compose.override.yml b/docker/compose.dev.yml similarity index 52% rename from compose.override.yml rename to docker/compose.dev.yml index 779cc82..9025456 100644 --- a/compose.override.yml +++ b/docker/compose.dev.yml @@ -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: - # Local services are available on their ports, but also available on: - # http://api.localhost.tiangolo.com: backend - # http://dashboard.localhost.tiangolo.com: frontend - # etc. To enable it, update .env, set: - # DOMAIN=localhost.tiangolo.com + # Local Traefik. Services stay reachable both on their published ports and + # on http://api.localhost / http://app.localhost via the routers in + # compose.yml. Dashboard at http://localhost:8090. proxy: image: traefik:3.6 volumes: @@ -12,37 +14,25 @@ services: ports: - "80:80" - "8090:8080" - # Duplicate the command from compose.yml to add --api.insecure=true command: - # Enable Docker in Traefik, so that it reads labels from Docker services - --providers.docker - # Add a constraint to only use services with the label for this stack - - --providers.docker.constraints=Label(`traefik.constraint-label`, `traefik-public`) - # Do not expose all Docker services, only the ones explicitly exposed + - --providers.docker.constraints=Label(`traefik.constraint-label`, `proxy`) - --providers.docker.exposedbydefault=false - # Create an entrypoint "http" listening on port 80 - --entrypoints.http.address=:80 - # Create an entrypoint "https" listening on port 443 - --entrypoints.https.address=:443 - # Enable the access log, with HTTP requests - --accesslog - # Enable the Traefik log, for configurations and errors - --log - # Enable debug logging for local development - --log.level=DEBUG - # Enable the Dashboard and API - --api - # Enable the Dashboard and API in insecure mode for local development - --api.insecure=true labels: - # Enable Traefik for this service, to make it available in the public network - traefik.enable=true - - traefik.constraint-label=traefik-public - # Dummy https-redirect middleware that doesn't really redirect, only to - # allow running it locally + - traefik.constraint-label=proxy + # Dummy https-redirect middleware that doesn't really redirect, so the + # production routers' middleware reference still resolves locally. - traefik.http.middlewares.https-redirect.contenttype.autodetect=false networks: - - traefik-public + - proxy - default db: @@ -53,16 +43,15 @@ services: adminer: restart: "no" ports: - - "8080:8080" + - "127.0.0.1:8080:8080" backend: restart: "no" ports: - "8000:8000" build: - context: . + context: .. dockerfile: backend/Dockerfile - # command: sleep infinity # Infinite loop to keep container alive doing nothing command: - fastapi - run @@ -70,22 +59,21 @@ services: - "app/main.py" develop: watch: - - path: ./backend + - path: ../backend action: sync target: /app/backend ignore: - - ./backend/.venv + - ../backend/.venv - .venv - - path: ./backend/pyproject.toml + - path: ../backend/pyproject.toml action: rebuild - # TODO: remove once coverage is done locally volumes: - - ./backend/htmlcov:/app/backend/htmlcov + - ../backend/htmlcov:/app/backend/htmlcov environment: SMTP_HOST: "mailcatcher" SMTP_PORT: "1025" SMTP_TLS: "false" - EMAILS_FROM_EMAIL: "noreply@example.com" + EMAILS_FROM_EMAIL: "noreply@fluksio.com" mailcatcher: image: schickling/mailcatcher @@ -98,7 +86,7 @@ services: ports: - "5173:80" build: - context: . + context: .. dockerfile: frontend/Dockerfile args: - VITE_API_URL=http://localhost:8000 @@ -106,7 +94,7 @@ services: playwright: build: - context: . + context: .. dockerfile: frontend/Dockerfile.playwright args: - VITE_API_URL=http://backend:8000 @@ -116,20 +104,20 @@ services: - backend - mailcatcher env_file: - - .env + - ../.env environment: - VITE_API_URL=http://backend:8000 - MAILCATCHER_HOST=http://mailcatcher:1080 - # For the reports when run locally - PLAYWRIGHT_HTML_HOST=0.0.0.0 - CI=${CI} volumes: - - ./frontend/blob-report:/app/frontend/blob-report - - ./frontend/test-results:/app/frontend/test-results + - ../frontend/blob-report:/app/frontend/blob-report + - ../frontend/test-results:/app/frontend/test-results ports: - 9323:9323 networks: - traefik-public: - # For local dev, don't expect an external Traefik network + # Self-contained dev: create a stack-local network instead of expecting the + # shared one. compose.local.yml flips this back for the integrated stack. + proxy: external: false diff --git a/docker/compose.local.yml b/docker/compose.local.yml new file mode 100644 index 0000000..dc7d848 --- /dev/null +++ b/docker/compose.local.yml @@ -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 diff --git a/compose.traefik.yml b/docker/compose.traefik.yml similarity index 92% rename from compose.traefik.yml rename to docker/compose.traefik.yml index bcd7d14..b5b2088 100644 --- a/compose.traefik.yml +++ b/docker/compose.traefik.yml @@ -10,8 +10,8 @@ services: labels: # Enable Traefik for this service, to make it available in the public network - traefik.enable=true - # Use the traefik-public network (declared below) - - traefik.docker.network=traefik-public + # Use the proxy network (declared below) + - traefik.docker.network=proxy # Define the port inside of the Docker service to use - traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080 # 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 - /var/run/docker.sock:/var/run/docker.sock:ro # Mount the volume to store the certificates - - traefik-public-certificates:/certificates + - traefik-certificates:/certificates command: # Enable Docker in Traefik, so that it reads labels from Docker services - --providers.docker @@ -64,14 +64,14 @@ services: networks: # Use the public network created to be shared between Traefik and # any other service that needs to be publicly available with HTTPS - - traefik-public + - proxy volumes: # Create a volume to store the certificates, even if the container is recreated - traefik-public-certificates: + traefik-certificates: 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 - traefik-public: + proxy: external: true diff --git a/compose.yml b/docker/compose.yml similarity index 75% rename from compose.yml rename to docker/compose.yml index 2488fc0..d5e964c 100644 --- a/compose.yml +++ b/docker/compose.yml @@ -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: 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 @@ -12,7 +22,7 @@ services: volumes: - app-db-data:/var/lib/postgresql/data/pgdata env_file: - - .env + - ../.env environment: - PGDATA=/var/lib/postgresql/data/pgdata - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} @@ -21,34 +31,29 @@ services: 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: - - traefik-public - default depends_on: - db environment: - 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: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + container_name: fluksio-prestart build: - context: . + context: .. dockerfile: backend/Dockerfile + security_opt: + - no-new-privileges:true networks: - - traefik-public + - proxy - default depends_on: db: @@ -56,7 +61,7 @@ services: restart: true command: bash scripts/prestart.sh env_file: - - .env + - ../.env environment: - DOMAIN=${DOMAIN} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} @@ -78,9 +83,12 @@ services: backend: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + container_name: fluksio-api restart: always + security_opt: + - no-new-privileges:true networks: - - traefik-public + - proxy - default depends_on: db: @@ -89,7 +97,7 @@ services: prestart: condition: service_completed_successfully env_file: - - .env + - ../.env environment: - DOMAIN=${DOMAIN} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} @@ -116,12 +124,12 @@ services: retries: 5 build: - context: . + context: .. dockerfile: backend/Dockerfile labels: - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.constraint-label=traefik-public + - traefik.docker.network=proxy + - traefik.constraint-label=proxy - 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.certresolver=le - # Enable redirection for HTTP and HTTPS - 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: - - traefik-public + - proxy - default build: - context: . + context: .. dockerfile: frontend/Dockerfile args: - VITE_API_URL=https://api.${DOMAIN?Variable not set} - NODE_ENV=production labels: - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.constraint-label=traefik-public + - 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(`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-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.tls=true - 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 + volumes: app-db-data: networks: - traefik-public: - # Allow setting it to false for testing + # Shared with the website stack and whatever reverse proxy fronts them. + # Created once by the workspace root's scripts/setup.sh. + proxy: external: true