diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..381fc4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Repo-root .dockerignore — applies to `docker compose build` (which builds +# both backend and frontend contexts from the repo root). Excludes anything +# that should never end up in a build context. + +.worktrees/ +.git/ +.github/ +docs/prodfiles/ +*.production.txt +node_modules/ +dist/ +.venv/ +.superpowers/brainstorm/ diff --git a/docker-compose.yml b/docker-compose.yml index 2601fba..554ecfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,101 +1,98 @@ -# Cyclone — local docker-compose stack. -# -# Two services on a user-defined network: -# -# frontend (nginx) — published on http://127.0.0.1:8080 -# serves the built SPA + reverse-proxies /api/* to backend -# backend (uvicorn) — NOT published externally by default; reachable from -# the frontend over the compose network on backend:8000 -# (override `ports:` below to expose it for curl/debug) -# -# Persistent state: -# - `cyclone-data` named volume mounted at /data in the backend holds -# the SQLite database file. Survives `docker compose down`; only -# `docker compose down -v` wipes it. -# - `config/payers.yaml` is baked into the backend image at build time. -# To edit payer config, change the YAML, rebuild the backend image, -# and `POST /api/admin/reload-config` (or just restart the container). -# -# Usage: -# docker compose up -d --build # start (or rebuild + restart) -# docker compose logs -f # tail both services -# docker compose restart backend # bounce the backend (e.g. after crash) -# docker compose down # stop containers, KEEP the data volume -# docker compose down -v # stop AND wipe the data volume - 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: . - dockerfile: backend/Dockerfile - image: cyclone-backend:local - container_name: cyclone-backend + context: ./backend restart: unless-stopped environment: - # Bind on all interfaces inside the container (required — 127.0.0.1 - # would only be reachable from inside the container itself). - CYCLONE_HOST: "0.0.0.0" - CYCLONE_PORT: "8000" - CYCLONE_RELOAD: "0" - # Absolute path inside the container; the named volume mounts at /data. - CYCLONE_DB_URL: "sqlite:////data/cyclone.db" - # Bootstrap admin (required on first boot unless at least one user - # already exists). The ${VAR:?msg} syntax makes docker-compose refuse - # to start with a clear error if the env var isn't set in the host - # environment. - CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?CYCLONE_ADMIN_USERNAME is required on first boot} - CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?CYCLONE_ADMIN_PASSWORD is required on first boot (min 12 chars)} + # 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" + secrets: + - cyclone_db_key + - cyclone_admin_username + - cyclone_admin_password volumes: - - cyclone-data:/data - # The healthcheck in the Dockerfile hits /api/health. The frontend - # depends_on `service_healthy` so it won't accept traffic until the - # backend is responsive. + - 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", "--fail", "--silent", "http://127.0.0.1:8000/api/health"] - interval: 15s + test: ["CMD", "curl", "-fs", "http://localhost:8000/api/healthz"] + interval: 30s timeout: 5s retries: 3 - start_period: 10s - # No `ports:` — the frontend reaches the backend over the compose - # network. Uncomment the next line to expose the API directly for - # `curl http://localhost:8000/api/...` from the host. - # ports: - # - "127.0.0.1:8000:8000" + start_period: 30s networks: - - cyclone-net + - cyclone_network frontend: + image: cyclone-frontend:${TAG:-stable} build: - context: . - dockerfile: frontend/Dockerfile - image: cyclone-frontend:local - container_name: cyclone-frontend + context: ./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 - ports: - # Bind address defaults to 0.0.0.0 (reachable from the LAN). Set - # CYCLONE_BIND_ADDRESS=127.0.0.1 to tighten to loopback-only and - # match the standalone install's local-only posture. - # Port defaults to 8081 to dodge the common clash on 8080; override - # with CYCLONE_WEB_PORT=... (see README). - - "${CYCLONE_BIND_ADDRESS:-0.0.0.0}:${CYCLONE_WEB_PORT:-8081}:80" - healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/healthz"] - interval: 15s - timeout: 3s - retries: 3 - start_period: 5s networks: - - cyclone-net + - cyclone_network -networks: - cyclone-net: - driver: bridge +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-data: - name: cyclone-data \ No newline at end of file + cyclone_db: + cyclone_backups: + cyclone_prodfiles: + cyclone_sftp_staging: + cyclone_logs: + +networks: + cyclone_network: + driver: bridge