Auth landed in main on 2026-06-23 (migrations 0013/0014, cyclone.auth package, bcrypt + matrix_gate, Login.tsx + AuthProvider + RoleGate, CYCLONE_AUTH_DISABLED escape hatch). The original draft was written 2026-06-22 and proposed argon2id, admin/operator/viewer roles, a fresh migration 0012, and an inline require_role dependency. All of that is already in main. Add a 1.1 Delta vs. main section and switch role names from operator to user to match cyclone.auth.permissions.Role.
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.authpackage,users/sessionsSQLAlchemy models (migrations0013+0014), bcrypt password hashing, thePERMISSIONSmatrix +matrix_gateapp-wide dependency,Login.tsx+AuthProvider+RequireAuth+RoleGate, thecyclone adminCLI, andCYCLONE_AUTH_DISABLED=1escape 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 +
:stablealiases, §10 docker-compose.yml, §11 healthcheck + restart policy, §12.2tests/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 usesadmin / user / viewer. All references tooperatorin this spec should be read asuseragainst the live codebase. - Hashing algorithm reconciliation: the original draft specified
argon2id. Main usesbcrypt. 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 pullupdates 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:
git clone <repo> /opt/cyclone && cd /opt/cyclonedocker compose buildbash scripts/cyclone-init.sh→ generates/etc/cyclone/secrets/{db,secret,admin_pw}.key, prints the admin password oncedocker compose up -d- Open
http://<lan-ip>:8080/login→ log in asadmin, change password, create operators - Upload parsed 837s, edit claims, export ZIPs, hand to MFT UI
- Trust daily encrypted backups + the operator's off-box cron copy for ≤24h loss
2. Goals
- Dockerize the existing stack so it runs the same way in production as in development. One repo, one
docker compose up -d. - Add app-layer authentication with username/password + 3-role RBAC, replacing the current "no auth at all" posture.
- Encrypt the SQLite DB at rest via SQLCipher; key as a Docker secret (not an env var, not a keychain on macOS).
- Wire the existing encrypted backup system to autostart on container boot with sane defaults (24h interval, 14-day retention).
- Add an operator runbook + bootstrap scripts so the system is reproducible from
git cloneand supportable without tribal knowledge. - 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 | mailcron 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-passwordCLI. - 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 hashingcyclone.authmodule:User/SessionSQLAlchemy models, password hashing helpers, lockout logiccyclone.api.authroutes:/api/auth/{login,logout,change-password,me}cyclone.api.usersroutes:/api/admin/users(admin-only CRUD)cyclone.cliadditions:cyclone admin {create-user,reset-password,list-users}- Migration
0012_users_sessions.sqlforusers+sessionstables - Existing
BackupService(SP17) andaudit_logmachinery 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(matchesrequires-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.conffor SPA routing + reverse proxy to backendDockerfile.frontend: multi-stage builder (node:20-alpine runsnpm ci && npm run build) + runtime (nginx:1.27-alpine servingdist/)useCurrentUser()hook (react-query)Login.tsxpage<AuthGate>wrapper inApp.tsx<NavBar>shows username + role badge + logout button/admin/userspage (admin-only)
Infrastructure:
docker-compose.ymlat repo rootscripts/cyclone-init.sh— generates/etc/cyclone/secrets/*withopenssl rand -hex 32scripts/post-deploy.sh— sets up logrotate + healthcheck cron on hostscripts/smoke.sh— bring-up + login + parse + export end-to-end testRUNBOOK.md— daily/weekly/quarterly/as-needed ops procedures.dockerignorefiles 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://<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://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
@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 >= 5within 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://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.jsonincyclone_backupsvolume CYCLONE_BACKUP_AUTOSTART=1in compose starts the in-container backup scheduler on container startCYCLONE_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"becomesactor="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 viadocker logs - Bind-mount
/var/log/cyclone/from the host so logrotate can manage retention (configured inscripts/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 intorequest.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/healthzevery 30s, 3 retries (configured in Dockerfile) - Compose
restart: unless-stoppedon healthcheck failure (max 3 restarts then backoff) GET /api/healthzreturns{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.shduring 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 bydocker compose build(most recent build)stable— manually promoted by the operator once a release has run in prod for ≥1 week without issues, viadocker tag cyclone-backend:0.1.0 cyclone-backend:stable && docker tag cyclone-frontend:0.1.0 cyclone-frontend:stable. Compose defaults to:stableso 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-logpage 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; logauth.login_failed; 423 on locked accounts. - Session expiry: 401; frontend redirects to
/loginwithstate.frompreserved. - 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;BackupServicerefuses to run (existing behavior); compose logs a warning. - Migration failure on startup: container exits with non-zero; compose
restart: unless-stoppedbacks off after 3 attempts; operator checks logs viadocker compose logs backend. - Backup failure: logged at ERROR; audit event
backup.failed; existing retry behavior inBackupService.
12. Testing
12.1 New unit tests
tests/test_auth.py(~12 tests)test_create_user_with_argon2_hashtest_login_with_correct_password_returns_sessiontest_login_with_wrong_password_increments_failed_counttest_login_locks_account_after_5_failurestest_login_lock_clears_after_15mintest_session_cookie_is_httponly_secure_samesite_laxtest_session_expires_after_sliding_windowtest_session_absolute_lifetime_enforcedtest_logout_deletes_session_and_clears_cookietest_change_password_invalidates_other_sessionstest_require_role_rejects_below_minimumtest_require_role_returns_user_object_to_dependency
tests/test_users.py(~6 tests)test_admin_can_create_usertest_operator_cannot_create_usertest_admin_can_change_user_roletest_admin_can_disable_usertest_disabled_user_cannot_logintest_audit_event_for_user_lifecycle
Total new backend tests: ~18.
12.2 Docker smoke tests
tests/test_docker.py(~4 tests)test_compose_config_validatestest_dockerfile_backend_buildstest_dockerfile_frontend_buildstest_compose_up_brings_up_healthy_stack(usestestcontainers-pythonor skips if not available; gated on aDOCKER_TESTS=1env 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://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/<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:
- Stop the existing server.
- 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). - Run
scripts/cyclone-init.shto generate secrets. - Run
docker compose up -d— the backend starts with an empty SQLCipher DB. Migrations run. - Bootstrap admin user via the CLI in the container.
- 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 -vpasses- All previously-passing tests still pass; new total ≥ previous + 18
docker compose configvalidates with no errorsdocker compose buildsucceeds for both servicesdocker compose up -dbrings both containers tohealthywithin 60scurl http://localhost:8080/api/healthzreturns{db_ok: true, scheduler_running: true}
15.2 Auth + RBAC
POST /api/auth/loginwith correct creds setscyclone_sessioncookie (HttpOnly, Secure, SameSite=Lax) and returns{user: {...}}- 5 wrong logins within 15min locks the account; 423 returned
GET /api/batcheswithout session cookie returns 401GET /api/admin/usersas 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/createcreates an encrypted.binin/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/usersas operator shows "Forbidden" page- Logout button clears session and returns to
/login npm run buildproduces adist/that serves correctly from nginx
15.5 Smoke
scripts/smoke.shpasses end-to-end against a fresh stackRUNBOOK.mdexists and contains the §9.6 proceduresscripts/post-deploy.shsets up logrotate + healthcheck cron without errors