# 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](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:**

```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`

```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 <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`

```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 <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

```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 <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

```ts
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`:
```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`):
  ```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 <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

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

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