# Cyclone Ubuntu Docker Deployment — Design **Date:** 2026-06-22 **Status:** Approved (pending user review of this doc) **Depends on:** [2026-06-19-cyclone-production-readiness-design.md](2026-06-19-cyclone-production-readiness-design.md) (in-memory store + react-query wiring; local-only) **Replaces:** The "no auth, no Docker, local-only" posture of the parent spec with a production-grade posture for real PHI on a single Ubuntu server. --- ## 1. Overview Cyclone's first sub-project shipped a usable local-only system: parse 837/835 files, browse the data, edit and resubmit rejected claims, export a corrected 837 ZIP. Everything ran on `127.0.0.1` with no auth, in-memory storage, and a `python -m cyclone serve` invocation. This sub-project graduates that to a production-grade deployment on a single Ubuntu Linux server: - **Two-container Docker Compose** (backend + frontend) replacing the dev-server split - **App-layer authentication** with username + password, argon2id hashing, session cookies, and three fixed roles (admin / operator / viewer) - **SQLCipher encryption at rest** for the SQLite DB, with the key as a Docker secret - **Daily encrypted backups** with the existing AES-256-GCM `BackupService`, scheduler autostart, 14-day retention - **LAN-only bind** (single operator, single Ubuntu server); VPN handles outside access - **Manual `docker compose pull` updates** with semver tags; one-env-var rollback - **Operator runbook** + bootstrap scripts so the deploy is reproducible Real PHI flows through this deployment. The SFTP wire stays stubbed — operators download the corrected 837 ZIP and hand it to the Gainwell MFT UI manually. That's intentional and documented as v1 scope; SFTP wire is v2. After this ships, the operator can: 1. `git clone /opt/cyclone && cd /opt/cyclone` 2. `docker compose build` 3. `bash scripts/cyclone-init.sh` → generates `/etc/cyclone/secrets/{db,secret,admin_pw}.key`, prints the admin password once 4. `docker compose up -d` 5. Open `http://:8080/login` → log in as `admin`, change password, create operators 6. Upload parsed 837s, edit claims, export ZIPs, hand to MFT UI 7. Trust daily encrypted backups + the operator's off-box cron copy for ≤24h loss ## 2. Goals 1. **Dockerize the existing stack** so it runs the same way in production as in development. One repo, one `docker compose up -d`. 2. **Add app-layer authentication** with username/password + 3-role RBAC, replacing the current "no auth at all" posture. 3. **Encrypt the SQLite DB at rest** via SQLCipher; key as a Docker secret (not an env var, not a keychain on macOS). 4. **Wire the existing encrypted backup system** to autostart on container boot with sane defaults (24h interval, 14-day retention). 5. **Add an operator runbook + bootstrap scripts** so the system is reproducible from `git clone` and supportable without tribal knowledge. 6. **Preserve all existing functionality** — the parser, editor, exporter, scheduler, audit log, backup/restore flow all keep working unchanged from the user's perspective. ## 3. Non-goals (this sub-project) - **Real SFTP wire (paramiko)** — keep stub. Operators download ZIPs and use the MFT UI. Listed as v2. - **Public TLS / public domain / Caddy reverse proxy** — LAN-only bind for v1; VPN handles outside access. Listed as v2. - **Multi-host HA / DB replication / load balancing** — single Ubuntu server. Listed as v2. - **Prometheus / Grafana / real alerting** — v1 uses the simple `curl healthz | mail` cron pattern. Listed as v2. - **Off-box automated backup shipping** — operator's responsibility via host cron / rsync. Documented in runbook, not automated. - **2FA / SSO / OAuth** — v2. - **Self-service password reset** — v2; v1 admin resets via `cyclone admin reset-password` CLI. - **Per-file encryption of prodfiles / sftp_staging volumes** — v2; v1 relies on volume-level filesystem permissions + the SQLCipher-encrypted DB + the host being physically secure. - **LUKS full-disk encryption** — out of scope; assumed to be an operator-level concern (Ubuntu installer offers it). - **Automatic update mechanism** (Watchtower etc.) — v1 is manual `docker compose pull`. Listed as v2. - **New X12 transaction types or validation rules** — not this sub-project. ## 4. Stack **Backend additions:** - `argon2-cffi` (new dep) for password hashing - `cyclone.auth` module: `User` / `Session` SQLAlchemy models, password hashing helpers, lockout logic - `cyclone.api.auth` routes: `/api/auth/{login,logout,change-password,me}` - `cyclone.api.users` routes: `/api/admin/users` (admin-only CRUD) - `cyclone.cli` additions: `cyclone admin {create-user,reset-password,list-users}` - Migration `0012_users_sessions.sql` for `users` + `sessions` tables - Existing `BackupService` (SP17) and `audit_log` machinery reused unchanged **Backend Dockerfile** (`backend/Dockerfile`): - Multi-stage: builder stage installs `.[sqlcipher]` (sqlcipher is optional but recommended; the engine falls back to plain SQLite if the package isn't actually present, same graceful default as today), runtime stage copies installed site-packages + source - Base: `python:3.11-slim-bookworm` (matches `requires-python = ">=3.11"`) - Non-root user `cyclone` (uid 1000) - `HEALTHCHECK CMD curl -fs http://localhost:8000/api/healthz || exit 1` - Entrypoint: `python -m cyclone serve` **Frontend additions:** - `nginx.conf` for SPA routing + reverse proxy to backend - `Dockerfile.frontend`: multi-stage builder (node:20-alpine runs `npm ci && npm run build`) + runtime (nginx:1.27-alpine serving `dist/`) - `useCurrentUser()` hook (react-query) - `Login.tsx` page - `` wrapper in `App.tsx` - `` shows username + role badge + logout button - `/admin/users` page (admin-only) **Infrastructure:** - `docker-compose.yml` at repo root - `scripts/cyclone-init.sh` — generates `/etc/cyclone/secrets/*` with `openssl rand -hex 32` - `scripts/post-deploy.sh` — sets up logrotate + healthcheck cron on host - `scripts/smoke.sh` — bring-up + login + parse + export end-to-end test - `RUNBOOK.md` — daily/weekly/quarterly/as-needed ops procedures - `.dockerignore` files for both build contexts ## 5. Architecture ``` ┌─────────────────────────────────────────────────────┐ │ Ubuntu Linux server (your machine) │ │ │ │ cyclone_network (bridge) │ │ ┌─────────────────────┐ ┌────────────────────┐ │ │ │ cyclone-backend │ │ cyclone-frontend │ │ │ │ FastAPI │◄───┤ nginx (SPA + proxy)│ │ │ │ :8000 (internal) │ │ :8080 (host) │ │ │ └──────┬──────────────┘ └─────────┬──────────┘ │ │ │ │ │ └─────────┼─────────────────────────────┼────────────┘ │ │ ┌──────▼──────────────────┐ ┌──────▼──────────┐ │ Named volumes │ │ Bind mounts │ │ cyclone_db │ │ ./dist (built) │ │ cyclone_backups │ │ ./nginx.conf │ │ cyclone_prodfiles │ └─────────────────┘ │ cyclone_sftp_staging │ └─────────────────────────┘ /etc/cyclone/secrets/ (host, chmod 600, root:root) ├─ db.key → /run/secrets/cyclone_db_key ├─ secret.key → /run/secrets/cyclone_secret_key └─ admin_pw → /run/secrets/cyclone_admin_password ``` **Why two containers:** nginx serves the React build faster than FastAPI would, decouples frontend rebuild cadence from backend release cadence, and is the standard ops pattern. nginx also reverse-proxies `/api/*` to the backend so the browser only talks to one origin (no CORS needed, no preflight requests for the `Cookie` header). **Why LAN-only bind:** nginx listens on `0.0.0.0:8080` inside the host network namespace, but the operator is expected to bind to the LAN IP via firewall rules / not exposing on the WAN. VPN handles outside access. No public TLS needed for v1. **Container-to-container networking:** the frontend container reaches the backend at `http://cyclone-backend:8000` over the compose-managed bridge network. The browser only ever sees `http://:8080`. **Healthcheck:** container-level `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries. Compose `restart: unless-stopped` on healthcheck failure. **Reverse proxy in nginx:** ```nginx server { listen 8080; server_name _; root /usr/share/nginx/html; index index.html; # API + auth: proxy to backend location /api/ { proxy_pass http://cyclone-backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 300s; # long enough for big parse streams client_max_body_size 50m; # claims files can be large } # SPA: serve index.html for all non-/api routes location / { try_files $uri $uri/ /index.html; } } ``` ## 6. Backend changes ### 6.1 New module `backend/src/cyclone/auth/__init__.py` ```python @dataclass(frozen=True) class Role: VIEWER = "viewer" OPERATOR = "operator" ADMIN = "admin" ROLE_RANK = {Role.VIEWER: 0, Role.OPERATOR: 1, Role.ADMIN: 2} class User(Base): __tablename__ = "users" id: Mapped[str] # uuid4 hex username: Mapped[str] # UNIQUE password_hash: Mapped[str] # argon2id role: Mapped[str] # viewer | operator | admin created_at: Mapped[datetime] last_login_at: Mapped[datetime | None] failed_count: Mapped[int] = 0 locked_until: Mapped[datetime | None] = None class Session(Base): __tablename__ = "sessions" id: Mapped[str] # 32-byte hex token user_id: Mapped[str] created_at: Mapped[datetime] expires_at: Mapped[datetime] ip: Mapped[str | None] user_agent: Mapped[str | None] ``` Password hashing uses `argon2-cffi` with the OWASP-recommended parameters (`time_cost=2`, `memory_cost=19456`, `parallelism=1`, `hash_len=32`). ### 6.2 Auth routes in `backend/src/cyclone/api.py` (new router `auth_router`) | Method | Path | Behavior | Auth | |---|---|---|---| | POST | `/api/auth/login` | `{username, password}` → verify argon2id, increment `failed_count` on miss (lock at 5/15min), create session, set `HttpOnly; Secure; SameSite=Lax` cookie | none | | POST | `/api/auth/logout` | delete session row, clear cookie | any logged-in | | GET | `/api/auth/me` | `{user: {username, role}}` or 401 | any logged-in | | POST | `/api/auth/change-password` | `{old_password, new_password}` → verify, rehash | any logged-in | | POST | `/api/admin/users` | `{username, password, role}` → create user | admin | | GET | `/api/admin/users` | list users | admin | | PATCH | `/api/admin/users/{id}` | `{role?, password?}` | admin | | DELETE | `/api/admin/users/{id}` | soft-disable (set `disabled_at`) | admin | ### 6.3 The `require_role` dependency Every existing protected route gets a `dependencies=[Depends(require_role(Role.OPERATOR))]` annotation: ```python def require_role(min_role: str): """FastAPI dependency factory: read cookie, load user, enforce role.""" def dep(request: Request) -> User: sid = request.cookies.get("cyclone_session") if not sid: raise HTTPException(401, "not authenticated") with db.SessionLocal()() as s: sess = s.get(Session, sid) if sess is None or sess.expires_at < utcnow(): raise HTTPException(401, "session expired") user = s.get(User, sess.user_id) if user is None: raise HTTPException(401, "user gone") if user.locked_until and user.locked_until > utcnow(): raise HTTPException(423, "account locked") if ROLE_RANK[user.role] < ROLE_RANK[min_role]: raise HTTPException(403, f"requires {min_role}") request.state.user = user request.state.actor = user.username # existing audit-log contract return user return dep ``` **Role → action matrix:** | Action | viewer | operator | admin | |---|:-:|:-:|:-:| | View claims, batches, acks | ✓ | ✓ | ✓ | | `POST /api/parse-837`, `/api/parse-835` | — | ✓ | ✓ | | Edit claim fields, resubmit rejected | — | ✓ | ✓ | | `POST /api/batches/{id}/export-837` | — | ✓ | ✓ | | `POST /api/clearhouse/submit` | — | ✓ | ✓ | | `/api/admin/users` CRUD | — | — | ✓ | | `/api/admin/backup/*` | — | — | ✓ | | `/api/config/*` (clearhouse, payers) | — | — | ✓ | | `/api/admin/audit-log` | — | — | ✓ | | Backup scheduler on/off | — | — | ✓ | **Login rate-limit:** the existing `cyclone.api.security.request_rejected` machinery already rate-limits by IP; we add per-username lockout on top: - `failed_count >= 5` within 15min → `locked_until = now + 15min` - Locked accounts return 423; the lock clears after the cooldown ### 6.4 Audit log wiring The existing `audit_log.append(actor=..., ...)` calls already accept an actor string. The new auth dependency sets `request.state.actor = user.username`, and a small middleware reads it for every request. No audit-log schema change. ### 6.5 CLI additions ```bash cyclone admin create-user --username X --role admin --password cyclone admin reset-password --username X --new-password cyclone admin list-users ``` Used by `scripts/cyclone-init.sh` for the bootstrap admin, and by the operator for password recovery. ### 6.6 Migration `0012_users_sessions.sql` ```sql CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'operator', 'viewer')), created_at TIMESTAMP NOT NULL, last_login_at TIMESTAMP, failed_count INTEGER NOT NULL DEFAULT 0, locked_until TIMESTAMP, disabled_at TIMESTAMP ); CREATE INDEX idx_users_username ON users(username); CREATE TABLE sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMP NOT NULL, expires_at TIMESTAMP NOT NULL, ip TEXT, user_agent TEXT ); CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); ``` ### 6.7 Session timeout config | Setting | Default | Env var | |---|---|---| | Session absolute lifetime | 30 days | `CYCLONE_SESSION_ABSOLUTE_DAYS` | | Session sliding expiry | 8 hours | `CYCLONE_SESSION_SLIDING_HOURS` | | Cookie name | `cyclone_session` | `CYCLONE_COOKIE_NAME` | | Cookie `Secure` flag | true | always true in prod | ### 6.8 Backend Dockerfile ```dockerfile # Builder stage FROM python:3.11-slim-bookworm AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential libsqlcipher-dev libffi-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /build COPY pyproject.toml . COPY src/ ./src/ RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]' # Runtime stage FROM python:3.11-slim-bookworm RUN apt-get update && apt-get install -y --no-install-recommends \ libsqlcipher-dev curl tini \ && rm -rf /var/lib/apt/lists/* \ && useradd --create-home --uid 1000 cyclone WORKDIR /app COPY --from=builder /wheels /wheels RUN pip install --no-cache-dir --no-index --find-links /wheels cyclone COPY src/ /app/src/ USER cyclone ENV PYTHONUNBUFFERED=1 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD curl -fs http://localhost:8000/api/healthz || exit 1 ENTRYPOINT ["tini", "--"] CMD ["python", "-m", "cyclone", "serve"] ``` `sqlcipher` is included but the engine falls back to plain SQLite if the package isn't actually installed — same graceful default as today, just biased toward enabling encryption in prod. ## 7. Frontend changes ### 7.1 New login page `src/pages/Login.tsx` A simple form: username + password inputs, submit button, error message slot. On success, navigate to `/`. Lives outside the auth-gated shell, served from a route that doesn't require authentication. ```tsx export default function Login() { const login = useMutation({ mutationFn: ({ username, password }) => api.login(username, password), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }), }); // ... } ``` ### 7.2 `useCurrentUser` hook ```ts export function useCurrentUser() { return useQuery({ queryKey: ['me'], queryFn: () => api.me(), retry: false, staleTime: 60_000, }); } ``` ### 7.3 AuthGate in `App.tsx` ```tsx function AuthGate({ children }: { children: React.ReactNode }) { const { data: user, isLoading } = useCurrentUser(); const location = useLocation(); if (isLoading) return ; if (!user) return ; return <>{children}; } ``` Wrap every route except `/login` in ``. `/login` itself does *not* require auth. ### 7.4 Role-based UI gating ```ts const ROLE_RANK = { viewer: 0, operator: 1, admin: 2 }; export function useCan(minRole: 'viewer' | 'operator' | 'admin'): boolean { const { data: user } = useCurrentUser(); if (!user) return false; return ROLE_RANK[user.role] >= ROLE_RANK[minRole]; } ``` Used in NavBar to hide "Admin" links from operators/viewers, and on the `/admin/users` page as a guard (`if (!useCan('admin')) return `). ### 7.5 NavBar changes - Username + role badge (admin/operator/viewer) shown right-aligned - Logout button (calls `api.logout()`, clears react-query cache, navigates to `/login`) - "Admin" dropdown appears only when `useCan('admin')` ### 7.6 `/admin/users` page (admin only) Table of users (username, role, last login, status). Actions: - **Create user** (modal with username + password + role) - **Edit role** (inline select) - **Reset password** (modal) - **Disable / enable** (toggle; sets `disabled_at`) ### 7.7 api.ts additions ```ts api.login(username: string, password: string): Promise<{ user: User }> api.logout(): Promise api.me(): Promise<{ user: User }> // throws 401 if not logged in api.changePassword(oldPw: string, newPw: string): Promise api.listUsers(): Promise api.createUser(...): Promise api.updateUser(id: string, ...): Promise api.deleteUser(id: string): Promise ``` All `api.*` calls send `credentials: 'include'` so the session cookie flows. ### 7.8 Frontend Dockerfile + nginx.conf `frontend/Dockerfile`: ```dockerfile # Build stage FROM node:20-alpine AS builder WORKDIR /build COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # Runtime stage FROM nginx:1.27-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /build/dist /usr/share/nginx/html HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD wget -qO- http://localhost:8080/ >/dev/null || exit 1 ``` The nginx config shown in §5 lives at `frontend/nginx.conf`. ## 8. Data, secrets, backups ### 8.1 Named volumes | Volume | Mount path in container | Contents | |---|---|---| | `cyclone_db` | `/var/lib/cyclone/db/cyclone.db` | SQLCipher-encrypted SQLite | | `cyclone_backups` | `/var/lib/cyclone/backups/` | AES-256-GCM `.bin` + `.meta.json` | | `cyclone_prodfiles` | `/var/lib/cyclone/prodfiles/` | Uploaded 837 `.txt`, downloaded 835 | | `cyclone_sftp_staging` | `/var/lib/cyclone/sftp_staging/` | SFTP stub's outbound/inbound | These are managed by Docker (`docker volume create` / compose `volumes:`); they live under `/var/lib/docker/volumes/` on the host. ### 8.2 Docker secrets (host-managed) | File | Mounted as | Purpose | |---|---|---| | `/etc/cyclone/secrets/db.key` | `/run/secrets/cyclone_db_key` | SQLCipher `PRAGMA key` — 64 hex chars | | `/etc/cyclone/secrets/secret.key` | `/run/secrets/cyclone_secret_key` | Cookie signing key — 64 hex chars | | `/etc/cyclone/secrets/admin_pw` | `/run/secrets/cyclone_admin_password` | One-time bootstrap admin password | `scripts/cyclone-init.sh` generates all three with `openssl rand -hex 32` (db.key + secret.key) and a memorable-but-random admin password, sets permissions to `chmod 600 root:root`, and prints the admin password once. ### 8.3 Backup strategy (≤24h loss is acceptable) - The existing `BackupService` (SP17) is already implemented and tested: takes online snapshot via SQLite `.backup()` API, encrypts with AES-256-GCM, writes `.bin` + `.meta.json` in `cyclone_backups` volume - `CYCLONE_BACKUP_AUTOSTART=1` in compose starts the in-container backup scheduler on container start - `CYCLONE_BACKUP_INTERVAL_HOURS=24` (default) - 14-day retention (default; configurable via `CYCLONE_BACKUP_RETENTION_DAYS`) - Restore via existing `/api/admin/backup/{id}/restore/{initiate,confirm}` (admin-only after auth is wired) - Existing audit-log call `actor="backup-scheduler"` becomes `actor="backup-scheduler"` (unchanged — system actor, not a user) **Off-box backup copy** (operator's responsibility, documented in runbook): The operator sets up a host cron / systemd timer that rsyncs `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted so the destination doesn't need its own encryption. Documented in `RUNBOOK.md` as a required post-deploy step. ### 8.4 Data loss scenarios | Scenario | Outcome | |---|---| | Container crashes | Docker restarts; SQLite WAL recovers; no loss | | Disk corruption | Restore from latest local backup (≤24h old) | | Disk total loss | Restore from off-box backup copy (depends on operator's cron cadence) | | DB key compromise | Rotate via `POST /api/admin/db/rotate-key`; old backups re-encrypted on next cycle | | Operator forgets admin password | Reset via `cyclone admin reset-password --username admin --new-password X` | ### 8.5 Encryption-at-rest coverage | Asset | Encrypted at rest (v1) | Notes | |---|---|---| | Live SQLite DB | ✓ SQLCipher | AES-256, key in Docker secret | | Backup `.bin` files | ✓ AES-256-GCM | Key from backup passphrase | | Prodfiles (uploaded 837s) | — plain on disk | v2: per-file encryption or move to DB | | sftp_staging | — plain on disk | v2: per-file encryption | | Log files | — plain on disk | rotated, contained | v1 assumes the host is physically secure (single-operator server, locked room) and that LUKS is operator-level. ## 9. Logging, monitoring, updates, ops ### 9.1 Logging - JSON to stdout (already configured via `logging_config.py`) — Docker collects via `docker logs` - Bind-mount `/var/log/cyclone/` from the host so logrotate can manage retention (configured in `scripts/post-deploy.sh`) - Log levels: INFO in prod, DEBUG opt-in via `CYCLONE_LOG_LEVEL=DEBUG` - Every login event (success + failure) logged with username, ip, user-agent, role - Every state-changing API call carries `actor=` (existing audit-log machinery; we wire the auth user into `request.state.actor`) - New audit events: `auth.login`, `auth.logout`, `auth.login_failed`, `auth.password_changed`, `auth.user_created`, `auth.user_updated`, `auth.user_disabled` ### 9.2 Healthcheck - Container-level: `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries (configured in Dockerfile) - Compose `restart: unless-stopped` on healthcheck failure (max 3 restarts then backoff) - `GET /api/healthz` returns `{db_ok: bool, scheduler_running: bool, last_backup_at: timestamp}` (existing endpoint, unchanged) ### 9.3 Monitoring (v1 minimal) - No Prometheus/Grafana in v1 - Host-level cron: `curl -fs http://localhost:8080/api/healthz >/dev/null || echo "Cyclone down at $(date)" | mail -s "cyclone DOWN" you@example.com` - Set up by `scripts/post-deploy.sh` during initial bootstrap ### 9.4 Updates - Images tagged semver from `package.json` / `pyproject.toml`: `cyclone-backend:0.1.0`, `cyclone-frontend:0.1.0` - Two tag aliases per image: - `latest` — set automatically by `docker compose build` (most recent build) - `stable` — manually promoted by the operator once a release has run in prod for ≥1 week without issues, via `docker tag cyclone-backend:0.1.0 cyclone-backend:stable && docker tag cyclone-frontend:0.1.0 cyclone-frontend:stable`. Compose defaults to `:stable` so a new build doesn't get auto-rolled-out. - Update procedure (to pick up a new `:stable`): ```bash cd /opt/cyclone docker compose pull # pulls newer :stable tags docker compose up -d # recreates containers, preserves volumes + secrets ``` - DB migrations run automatically on backend start; migrations are forward-only with documented downgrade procedures (existing pattern) - Rollback: `TAG=0.0.9 docker compose up -d` (one env var override; compose reads `${TAG:-stable}` for the image tag) - Previous image stays in Docker's local cache for one cycle so rollback is offline ### 9.5 Initial bootstrap ```bash git clone /opt/cyclone && cd /opt/cyclone docker compose build # or: docker compose pull bash scripts/cyclone-init.sh # generates /etc/cyclone/secrets/*, prints admin password docker compose up -d # starts both containers # wait ~10s for migrations docker compose exec backend cyclone admin create-user \ --username admin --role admin --password-file /run/secrets/cyclone_admin_password # open browser to http://:8080/login # log in as admin, change password, create operators bash scripts/post-deploy.sh # logrotate + healthcheck cron ``` ### 9.6 Daily ops (operator runbook) - **Daily:** check `GET /api/healthz` (or trust the healthcheck email) - **Weekly:** review `/admin/audit-log` page for unusual activity - **Quarterly:** rotate DB key via `POST /api/admin/db/rotate-key` - **As needed:** create operators via `/admin/users`; restore from backup via the restore UI - **Annual:** rotate cookie signing key (re-login all users) Full procedures live in `RUNBOOK.md` shipped in the repo. ## 10. docker-compose.yml ```yaml name: cyclone services: backend: image: cyclone-backend:${TAG:-stable} build: ./backend restart: unless-stopped environment: CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db" CYCLONE_BACKUP_AUTOSTART: "1" CYCLONE_BACKUP_INTERVAL_HOURS: "24" CYCLONE_BACKUP_RETENTION_DAYS: "14" CYCLONE_LOG_LEVEL: "INFO" CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log" CYCLONE_LOG_JSON: "1" CYCLONE_SESSION_ABSOLUTE_DAYS: "30" CYCLONE_SESSION_SLIDING_HOURS: "8" CYCLONE_COOKIE_SECURE: "1" secrets: - cyclone_db_key - cyclone_secret_key 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/healthz"] interval: 30s timeout: 5s retries: 3 start_period: 30s networks: [cyclone_network] frontend: image: cyclone-frontend:${TAG:-stable} build: ./frontend restart: unless-stopped ports: - "8080:8080" depends_on: backend: condition: service_healthy networks: [cyclone_network] secrets: cyclone_db_key: file: /etc/cyclone/secrets/db.key cyclone_secret_key: file: /etc/cyclone/secrets/secret.key 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 ``` The `8080:8080` port is the only one published to the host. The operator is expected to bind to the LAN IP at the firewall level (UFW rules or similar). ## 11. Error handling - **Login failures:** increment `failed_count`; lock at 5 fails / 15min; log `auth.login_failed`; 423 on locked accounts. - **Session expiry:** 401; frontend redirects to `/login` with `state.from` preserved. - **Missing role:** 403 with explicit message; UI shows a "Forbidden" page instead of trying to render. - **Backend crash:** Docker healthcheck restarts the container; SQLite WAL recovers in-flight writes. - **DB key missing:** container starts but `db.is_encryption_enabled()` returns False; `BackupService` refuses to run (existing behavior); compose logs a warning. - **Migration failure on startup:** container exits with non-zero; compose `restart: unless-stopped` backs off after 3 attempts; operator checks logs via `docker compose logs backend`. - **Backup failure:** logged at ERROR; audit event `backup.failed`; existing retry behavior in `BackupService`. ## 12. Testing ### 12.1 New unit tests - `tests/test_auth.py` (~12 tests) - `test_create_user_with_argon2_hash` - `test_login_with_correct_password_returns_session` - `test_login_with_wrong_password_increments_failed_count` - `test_login_locks_account_after_5_failures` - `test_login_lock_clears_after_15min` - `test_session_cookie_is_httponly_secure_samesite_lax` - `test_session_expires_after_sliding_window` - `test_session_absolute_lifetime_enforced` - `test_logout_deletes_session_and_clears_cookie` - `test_change_password_invalidates_other_sessions` - `test_require_role_rejects_below_minimum` - `test_require_role_returns_user_object_to_dependency` - `tests/test_users.py` (~6 tests) - `test_admin_can_create_user` - `test_operator_cannot_create_user` - `test_admin_can_change_user_role` - `test_admin_can_disable_user` - `test_disabled_user_cannot_login` - `test_audit_event_for_user_lifecycle` Total new backend tests: **~18**. ### 12.2 Docker smoke tests - `tests/test_docker.py` (~4 tests) - `test_compose_config_validates` - `test_dockerfile_backend_builds` - `test_dockerfile_frontend_builds` - `test_compose_up_brings_up_healthy_stack` (uses `testcontainers-python` or skips if not available; gated on a `DOCKER_TESTS=1` env var) ### 12.3 Smoke script `scripts/smoke.sh`: ```bash #!/usr/bin/env bash set -euo pipefail # 1. Bring up stack docker compose up -d --build # 2. Wait for healthcheck for i in {1..30}; do if curl -fs http://localhost:8080/api/healthz > /dev/null; then break; fi sleep 2 done # 3. Login COOKIE_JAR=$(mktemp) curl -fs -c "$COOKIE_JAR" -X POST http://localhost:8080/api/auth/login \ -H 'Content-Type: application/json' \ -d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}" # 4. Parse a sample 837 curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/parse-837 \ -F "file=@docs/goodclaim.x12" # 5. List batches curl -fs -b "$COOKIE_JAR" http://localhost:8080/api/batches # 6. Export curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/batches//export-837 \ -H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \ -o /tmp/export.zip unzip -l /tmp/export.zip | grep -E '\.x12$' echo "smoke: OK" ``` ### 12.4 Existing tests - All existing backend tests must continue to pass - All existing frontend tests must continue to pass - Pre-existing prodfile smoke failures (rate-limit / orphan-set refs on 999/TA1) remain pre-existing and out of scope ## 13. Migration / rollout ### 13.1 First-time deploy For a brand-new install on a fresh Ubuntu 22.04 server, follow §9.5 above. No data to migrate — this is the first deployment. ### 13.2 Upgrading an existing dev install If an operator has been running `python -m cyclone serve` locally with the in-memory store or a plaintext SQLite file, the upgrade path is: 1. **Stop the existing server.** 2. **Export any production data:** SQLite is at `~/.local/share/cyclone/cyclone.db`. The operator takes a final plaintext backup *before* the SQLCipher transition; we don't try to encrypt-in-place (would need a different migration). 3. **Run `scripts/cyclone-init.sh`** to generate secrets. 4. **Run `docker compose up -d`** — the backend starts with an empty SQLCipher DB. Migrations run. 5. **Bootstrap admin user** via the CLI in the container. 6. **Re-import data** from the plaintext export (manual step; documented in RUNBOOK). The in-memory store from the parent spec is *lost* on upgrade (by design — local-only). The plaintext SQLite DB *can* be carried over manually if needed. ### 13.3 Updating the running stack ```bash cd /opt/cyclone docker compose pull # or: docker compose build docker compose up -d # recreates containers docker compose logs -f backend | head -200 # verify migrations + healthcheck ``` DB migrations run automatically on backend start; they're forward-only. To roll back the code (not the schema): `TAG=0.0.9 docker compose up -d`. ## 14. Out of scope (explicit) - Real SFTP wire (paramiko) - Public TLS / domain / Caddy reverse proxy in compose - Multi-host HA / DB replication / load balancing - Prometheus / Grafana / alerting beyond the simple email cron - Off-box automated backup shipping (operator's cron) - 2FA / SSO / OAuth - Self-service password reset - Per-file encryption of prodfiles / sftp_staging - LUKS full-disk encryption - Watchtower / automatic updates - Linting/pre-commit/dev tooling polish - Component / E2E browser tests (Playwright) ## 15. Acceptance checklist ### 15.1 Backend - [ ] `pytest tests/test_auth.py tests/test_users.py tests/test_docker.py -v` passes - [ ] All previously-passing tests still pass; new total ≥ previous + 18 - [ ] `docker compose config` validates with no errors - [ ] `docker compose build` succeeds for both services - [ ] `docker compose up -d` brings both containers to `healthy` within 60s - [ ] `curl http://localhost:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}` ### 15.2 Auth + RBAC - [ ] `POST /api/auth/login` with correct creds sets `cyclone_session` cookie (HttpOnly, Secure, SameSite=Lax) and returns `{user: {...}}` - [ ] 5 wrong logins within 15min locks the account; 423 returned - [ ] `GET /api/batches` without session cookie returns 401 - [ ] `GET /api/admin/users` as operator returns 403; as admin returns list - [ ] Logout clears the cookie and invalidates the session in the DB - [ ] Password change invalidates other sessions for that user ### 15.3 Encryption + backups - [ ] `cyclone.db_crypto.is_encryption_enabled()` returns True in container - [ ] Inspecting the DB file on disk shows binary ciphertext, not plaintext - [ ] `POST /api/admin/backup/create` creates an encrypted `.bin` in `/var/lib/cyclone/backups/` - [ ] Backup scheduler runs every 24h (`docker compose logs backend | grep backup-scheduler`) - [ ] Restore flow works end-to-end: create backup → wipe DB → restore → verify data present ### 15.4 Frontend - [ ] Unauthenticated browser request to `/` redirects to `/login` - [ ] Login as admin → land on `/`; NavBar shows username + role - [ ] Operator does not see "Admin" link in NavBar - [ ] `/admin/users` as operator shows "Forbidden" page - [ ] Logout button clears session and returns to `/login` - [ ] `npm run build` produces a `dist/` that serves correctly from nginx ### 15.5 Smoke - [ ] `scripts/smoke.sh` passes end-to-end against a fresh stack - [ ] `RUNBOOK.md` exists and contains the §9.6 procedures - [ ] `scripts/post-deploy.sh` sets up logrotate + healthcheck cron without errors