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" # Always bind to 0.0.0.0. The compose-managed bridge network # isolates the backend from the host's LAN/WAN — only the # `frontend` service joins it. Host firewall / port publishing # is the layer that controls what reaches us from outside the # compose network, not the bind address. 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