3ba5ca0849
After the 8-commit SP23 implementation landed, kicking the tires on
`docker compose build && docker compose up -d` (the gated DOCKER_TESTS=1
live test) surfaced five real bugs that don't show up in unit tests:
1. Backend wheel was built from the stub `__init__.py`, not the real
source. The Dockerfile's 'stub __init__, wheel, copy src, wheel
again' pattern silently kept the first wheel's contents — only
`__init__.py` got re-stubbed. The installed package had an empty
`__init__.py`, so `from cyclone import __version__` failed at import
time and the backend kept crashing in a restart loop.
Fix: single `COPY src/` + single `pip wheel`. Comment explains
why the stub trick is gone for good.
2. Backend binds to 127.0.0.1 (intentional — local-only by design,
see CLAUDE.md). But that means the frontend container can't reach
it over the compose bridge network — nginx got 'Connection refused'.
Fix: `CYCLONE_HOST` env var, defaults to 127.0.0.1 (preserves local
posture for non-Docker runs), set to 0.0.0.0 by the docker-compose
backend service. Network isolation is provided by the compose bridge
network (only `cyclone-frontend` joins).
3. Healthcheck probed `/api/healthz` (404 — the route is `/api/health`).
Same in: backend Dockerfile HEALTHCHECK, docker-compose healthcheck,
nginx.conf doesn't have one (frontend proxies through), RUNBOOK.md,
scripts/post-deploy.sh, scripts/smoke.sh.
Fix: `/api/healthz` → `/api/health` everywhere SP23 owns.
4. The auth matrix in `cyclone.auth.permissions` had
`("GET", "/api/healthz"): set()` — which is the WRONG path (the
route is `/api/health`). So even after fixing the healthcheck URL,
the public auth bypass wouldn't have applied to `/api/health` and
it would have been DENY-by-default (fail-closed).
Fix: matrix entry updated to `/api/health`.
5. nginx upstream pointed at `cyclone-backend` (the project+service
name), but compose v2 only resolves the bare service name (`backend`)
over the bridge network. nginx crashed at config-load with 'host not
found in upstream cyclone-backend'.
Fix: `cyclone-backend:8000` → `backend:8000` in nginx.conf + spec
+ plan.
6. Frontend HEALTHCHECK used `http://localhost:8080/`. nginx in the
alpine image listens on IPv6 (per the entrypoint's IPv6-by-default
script), so `localhost` (which prefers IPv6 `::1` in musl) connects,
but the resolved flow inside wget is unreliable. `127.0.0.1` works.
Fix: HEALTHCHECK uses `http://127.0.0.1:8080/`.
Also moves `frontend/Dockerfile` → `Dockerfile.frontend` and
`frontend/nginx.conf` → `nginx.conf` at repo root (because the
frontend lives at the repo root, not in `frontend/`, and compose's
`build.context: .` needs them at the same root as compose.yml).
The frontend's pre-existing `.dockerignore` was empty/unused, so it's
dropped — the root `.dockerignore` covers it.
Adds `docker-compose.override.yml` for local bring-up testing on a
host without sudo. Production uses `/etc/cyclone/secrets/` directly.
Verified end-to-end on this dev host with `DOCKER_TESTS=1`:
- Both containers `(healthy)` within ~60s
- `curl http://localhost:8080/api/health` → 200 with valid JSON
- Login as admin → 200, /api/auth/me → 200
- `POST /api/parse-837` with docs/goodclaim.x12 → 200, batch created
- Full backend test suite: 1014 passed, 9 skipped (prodfiles gitignored)
105 lines
3.7 KiB
YAML
105 lines
3.7 KiB
YAML
name: cyclone
|
|
|
|
# Two-container production stack for Cyclone.
|
|
#
|
|
# backend FastAPI on python:3.11-slim-bookworm, internal port 8000.
|
|
# frontend nginx:1.27-alpine serving the built React SPA + reverse-
|
|
# proxying /api/* to the backend, host port 8080.
|
|
#
|
|
# Tag strategy:
|
|
# - Images are tagged semver from package.json / pyproject.toml.
|
|
# - Two aliases per image: `:latest` (set automatically by `docker
|
|
# compose build`) and `:stable` (manually promoted after a release
|
|
# has been running in prod for >=1 week).
|
|
# - Compose defaults to `:stable` so a fresh `docker compose build`
|
|
# never auto-rolls a new release.
|
|
# - Override with `TAG=0.0.9 docker compose up -d` to roll back.
|
|
|
|
services:
|
|
backend:
|
|
image: cyclone-backend:${TAG:-stable}
|
|
build:
|
|
context: ./backend
|
|
restart: unless-stopped
|
|
environment:
|
|
# SQLite path lives on the named `cyclone_db` volume.
|
|
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
|
|
# Wire the existing BackupService (SP17) to autostart on container boot.
|
|
CYCLONE_BACKUP_AUTOSTART: "1"
|
|
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
|
|
CYCLONE_BACKUP_RETENTION_DAYS: "14"
|
|
# Logging — JSON to stdout (collected by `docker logs`) and to the
|
|
# bind-mounted file (collected by host-side logrotate).
|
|
CYCLONE_LOG_LEVEL: "INFO"
|
|
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
|
|
CYCLONE_LOG_JSON: "1"
|
|
# Cookie signing key defaults to the SQLCipher key. Override in
|
|
# production if you want to rotate them independently.
|
|
CYCLONE_SECRET_KEY_FILE: "/run/secrets/cyclone_db_key"
|
|
CYCLONE_COOKIE_SECURE: "1"
|
|
# First-admin bootstrap — populated by scripts/cyclone-init.sh.
|
|
# The `_FILE` variants are the standard Docker-secret pattern:
|
|
# the env var points at the mounted secret file rather than
|
|
# embedding the secret in the compose file.
|
|
CYCLONE_ADMIN_USERNAME_FILE: "/run/secrets/cyclone_admin_username"
|
|
CYCLONE_ADMIN_PASSWORD_FILE: "/run/secrets/cyclone_admin_password"
|
|
# Bind 0.0.0.0 so the frontend container on the compose bridge
|
|
# network can reach us. The bridge network provides isolation —
|
|
# only the `frontend` service is on it; the host firewall still
|
|
# blocks anything that isn't on the LAN.
|
|
CYCLONE_HOST: "0.0.0.0"
|
|
secrets:
|
|
- cyclone_db_key
|
|
- cyclone_admin_username
|
|
- cyclone_admin_password
|
|
volumes:
|
|
- cyclone_db:/var/lib/cyclone/db
|
|
- cyclone_backups:/var/lib/cyclone/backups
|
|
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
|
|
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
|
|
- cyclone_logs:/var/log/cyclone
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 30s
|
|
networks:
|
|
- cyclone_network
|
|
|
|
frontend:
|
|
image: cyclone-frontend:${TAG:-stable}
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile.frontend
|
|
restart: unless-stopped
|
|
ports:
|
|
# LAN-bind: only this port is published to the host. The operator
|
|
# is expected to firewall this to the LAN subnet (or rely on VPN
|
|
# for outside access). NO public TLS in v1.
|
|
- "8080:8080"
|
|
depends_on:
|
|
backend:
|
|
condition: service_healthy
|
|
networks:
|
|
- cyclone_network
|
|
|
|
secrets:
|
|
cyclone_db_key:
|
|
file: /etc/cyclone/secrets/db.key
|
|
cyclone_admin_username:
|
|
file: /etc/cyclone/secrets/admin_username
|
|
cyclone_admin_password:
|
|
file: /etc/cyclone/secrets/admin_pw
|
|
|
|
volumes:
|
|
cyclone_db:
|
|
cyclone_backups:
|
|
cyclone_prodfiles:
|
|
cyclone_sftp_staging:
|
|
cyclone_logs:
|
|
|
|
networks:
|
|
cyclone_network:
|
|
driver: bridge
|