Files
cyclone/docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md
T
Nora 3ba5ca0849 feat(sp23): live-verification fixes from end-to-end bring-up
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)
2026-06-23 17:53:16 -06:00

38 KiB

Cyclone Ubuntu Docker Deployment — Design

Date: 2026-06-22 (spec drafted); 2026-06-23 (reconciled with auth work that landed in main on 2026-06-23) Status: Approved (2026-06-23) Branch: sp23-ubuntu-docker-deployment Aesthetic direction: No new UI surface in v1 — the nginx + reverse-proxy posture is invisible to the operator; auth + dashboard styling is unchanged from what's already on main. Depends on: 2026-06-19-cyclone-production-readiness-design.md (in-memory store + react-query wiring; local-only); the auth work that merged into main on 2026-06-23 (a25504b..39ae988, the feat(auth): series). 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.1 Delta vs. main at 2026-06-23

The auth work that landed in main before SP23 began means sections 6 (auth modules + routes + matrix) and 7 (frontend login + AuthProvider + RoleGate) describe code that already exists on main. SP23's delta is therefore narrower than the original draft:

  • §6 (auth) — already on main. The cyclone.auth package, users / sessions SQLAlchemy models (migrations 0013 + 0014), bcrypt password hashing, the PERMISSIONS matrix + matrix_gate app-wide dependency, Login.tsx + AuthProvider + RequireAuth + RoleGate, the cyclone admin CLI, and CYCLONE_AUTH_DISABLED=1 escape hatch are all merged. SP23 reuses them; it does not re-implement. §6 is kept in this spec as the contract that Docker + secrets + compose env vars must satisfy, not as work to do.
  • §7 (login UI) — already on main. Reused.
  • §6.6 (new migration) — N/A. No new SQL migration is needed for SP23; auth tables already exist.
  • What SP23 adds (the actual work): §6.8 backend Dockerfile, §7.8 frontend Dockerfile + nginx.conf, §8.2 Docker secrets wiring, §9.4 image tag strategy + :stable aliases, §10 docker-compose.yml, §11 healthcheck + restart policy, §12.2 tests/test_docker.py, §9.5 bootstrap script, §9.6 RUNBOOK.md, post-deploy + smoke scripts.
  • Role name reconciliation: the original draft used admin / operator / viewer. Main uses admin / user / viewer. All references to operator in this spec should be read as user against the live codebase.
  • Hashing algorithm reconciliation: the original draft specified argon2id. Main uses bcrypt. The Docker secret posture is unaffected — both algorithms need only a key file or a salt, neither of which SP23 introduces.
  • Lockout timing: original draft was 5 fails / 15 min. Auth on main is 5 fails / 5 min (cyclone.auth.rate_limit). SP23 inherits the main behavior.

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 <repo> /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://<lan-ip>: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
  • <AuthGate> wrapper in App.tsx
  • <NavBar> 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://backend:8000 over the compose-managed bridge network. The browser only ever sees http://<lan-ip>: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:

server {
  listen 8080;
  server_name _;

  root /usr/share/nginx/html;
  index index.html;

  # API + auth: proxy to backend
  location /api/ {
    proxy_pass http://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

@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:

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

cyclone admin create-user --username X --role admin --password <from_stdin or --password-file>
cyclone admin reset-password --username X --new-password <from_stdin or --password-file>
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

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

# 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.

export default function Login() {
  const login = useMutation({
    mutationFn: ({ username, password }) => api.login(username, password),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }),
  });
  // ...
}

7.2 useCurrentUser hook

export function useCurrentUser() {
  return useQuery({
    queryKey: ['me'],
    queryFn: () => api.me(),
    retry: false,
    staleTime: 60_000,
  });
}

7.3 AuthGate in App.tsx

function AuthGate({ children }: { children: React.ReactNode }) {
  const { data: user, isLoading } = useCurrentUser();
  const location = useLocation();
  if (isLoading) return <FullPageSkeleton />;
  if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
  return <>{children}</>;
}

Wrap every route except /login in <AuthGate>. /login itself does not require auth.

7.4 Role-based UI gating

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 <Forbidden />).

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

api.login(username: string, password: string): Promise<{ user: User }>
api.logout(): Promise<void>
api.me(): Promise<{ user: User }>     // throws 401 if not logged in
api.changePassword(oldPw: string, newPw: string): Promise<void>
api.listUsers(): Promise<User[]>
api.createUser(...): Promise<User>
api.updateUser(id: string, ...): Promise<User>
api.deleteUser(id: string): Promise<void>

All api.* calls send credentials: 'include' so the session cookie flows.

7.8 Frontend Dockerfile + nginx.conf

frontend/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://127.0.0.1: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=<user_id> (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://127.0.0.1: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):
    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

git clone <repo> /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://<lan-ip>: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

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:

#!/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://127.0.0.1:8080/api/healthz > /dev/null; then break; fi
  sleep 2
done

# 3. Login
COOKIE_JAR=$(mktemp)
curl -fs -c "$COOKIE_JAR" -X POST http://127.0.0.1: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://127.0.0.1:8080/api/parse-837 \
  -F "file=@docs/goodclaim.x12"

# 5. List batches
curl -fs -b "$COOKIE_JAR" http://127.0.0.1:8080/api/batches

# 6. Export
curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/batches/<id>/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

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://127.0.0.1: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