Skip to content

Redis, migrations & environment

This page is the operational reference for the moving parts around the API: Redis, the database migrations, the environment variables, and the container topology.

Redis is optional but recommended. It is accessed through Bun’s native client (Bun.redis) — no external dependency. When REDIS_URL is unset (or under NODE_ENV=test), each feature falls back to an in-memory, process-scoped implementation, so the API still runs; you lose distribution and persistence, not functionality.

Feature What Redis provides Fallback without Redis
Token blocklist distributed, persistent revocation. Keys use absolute expiry (SET … EXAT) so entries auto-evict. in-memory Map<jti, exp> (single process)
Rate limiter fixed-window counters (INCR + PEXPIRE) shared across instances. in-memory counters (single process)
Stats real-time day-counters (INCRBY / GET, EXPIRE 3d); flushed to daily_stats via a Redis SCAN that covers all instances. in-memory counters + a touched set
Job wakeup low-latency worker wakeups over a list (BRPOP). the worker polls on a timer

Set it once for the whole stack:

Terminal window
REDIS_URL=redis://redis:6379

Migrations live in apps/api/drizzle/ and are applied with drizzle-kit. Run them from the API workspace:

Terminal window
# from apps/api
bun run db:migrate # apply pending migrations
bun run db:generate # generate a new migration from schema changes
bun run db:check # validate the migration chain
bun run db:studio # open Drizzle Studio

Inside Compose, the api service runs bun run db:migrate on boot (idempotent and fault-tolerant, so a failure never blocks startup). To run it manually against a running container:

Terminal window
podman compose exec -T api sh -c "cd /app/apps/api && bun run db:migrate"
Migration Introduces
0000_foamy_cassandra_nova initial schema — projects, keys, integrations, plans, users, files, compression_jobs, daily_stats, system_settings, audit_logs
0001_icy_chronomancer follow-up schema adjustments
0002_eager_madame_masque schema adjustments
0002_password_reset_tokens password_reset_tokens (token hash, account, expiry, single-use)
0003_dizzy_red_ghost spaces table + files.space_id (nullable FK, ON DELETE CASCADE)
0004_ambitious_mac_gargan spaces.active_token_jti (single-device last-wins)
0005_bizarre_mercury projects.rate_limit_max / rate_limit_window (per-project tier)
0006_clear_eternity compression_jobs.result jsonb (compression metrics)
0007_illegal_maria_hill compression_jobs.attempts (dead-letter after MAX_ATTEMPTS)
0008_great_jasper_sitwell orphaned_objects (R2 keys whose rollback cleanup failed, retried by the worker)
0009_small_nightshade two_factor — encrypted TOTP secrets + recovery codes

The API validates its environment with Zod at startup and fails fast on an invalid config (apps/api/src/config/env.ts). Required variables have no default.

Variable Type Purpose
DATABASE_URL URL PostgreSQL connection string
JWT_SECRET string ≥ 32 chars signs all JWTs
R2_ACCOUNT_ID string Cloudflare R2 account
R2_ACCESS_KEY_ID string R2 access key
R2_SECRET_ACCESS_KEY string R2 secret
R2_BUCKET_NAME string target bucket
Variable Default Purpose
JWT_EXPIRES_IN 7d portal session lifetime
COOKIE_SECURE false set true in production (Secure-flag cookies)
API_CORS_ALLOW_ORIGINS * comma-separated allowed origins; also the CSRF origin allowlist
SCOPE_ENFORCEMENT enforce off | warn | enforce — the scope guard mode
USER_TOKEN_TTL_SECONDS 900 end-user token TTL (60–3600)
SPACE_TOKEN_TTL_SECONDS 86400 space token TTL (300–2592000)
TWOFA_ENC_KEY — optional override key for TOTP secret encryption-at-rest (AES-256-GCM). When unset, the key is derived from JWT_SECRET via HKDF-SHA256. Accepts 64 hex chars or a 32-byte Base64 value; any other value is HKDF-stretched to 32 bytes.
Variable Default Purpose
R2_PUBLIC_URL — base URL for public file URLs; omit if bucket is private-only
SIGNED_URL_EXPIRY_SECONDS 604800 signed-URL TTL for private files (60–604800)
Variable Default Purpose
REDIS_URL — enables distributed blocklist / rate limit / stats / wakeup
RATE_LIMIT_MAX 1000 global rate-limit fallback (per window)
RATE_LIMIT_WINDOW_MS 60000 global rate-limit window
STATS_FLUSH_INTERVAL_MS 600000 interval to flush Redis counters → daily_stats

Mail (all optional — console fallback when SMTP_HOST is unset)

Section titled “Mail (all optional — console fallback when SMTP_HOST is unset)”
Variable Default Purpose
SMTP_HOST — SMTP server; e.g. Mailpit at mailtip in Compose
SMTP_PORT 587 SMTP port (1025 for Mailpit)
SMTP_SECURE false true only for direct TLS (port 465)
SMTP_USER / SMTP_PASS — SMTP credentials
SMTP_FROM noreply@localhost From address
MAIL_RESET_URL_BASE — base URL for password-reset links (?token= appended)

Bootstrap / seed (used only by seed scripts)

Section titled “Bootstrap / seed (used only by seed scripts)”
Variable Purpose
SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD initial super admin (bun run seed:admin)
DEMO_INTEGRATION_API_KEY demo integration key; must equal demo-api’s INTEGRATION_API_KEY (bun run seed:demo)

Other runtime basics: NODE_ENV (development | test | production, default development) and PORT (default 3000).

The stack is defined in compose.yml (project name wsw). Every HTTP-facing service publishes a VIRTUAL_HOST under *.wsw.local for the reverse proxy.

Service Image Host Role
api oven/bun:alpine api.wsw.local the Hono API; migrates + seeds on boot
worker oven/bun:alpine — (no HTTP) image-compression worker (internal only)
portal oven/bun:alpine portal.wsw.local admin / project portal (Vue)
demo oven/bun:alpine demo.wsw.local reference integration front-end (Vue)
demo-api oven/bun:alpine demo-api.wsw.local reference integration backend; seeds demo content
postgres postgres:17-alpine — primary database
redis redis:alpine — blocklist / rate limit / stats / wakeup
demo-mysql mysql:8.0 — demo-api’s own database
adminer adminer:latest adminer.wsw.local DB browser
mailtip axllent/mailpit mailpit.wsw.local dev SMTP capture (UI on 8025, SMTP on 1025)

Two networks: internal (service-to-service; Postgres, Redis, MySQL are internal-only) and proxy-network (external, shared with the reverse proxy). Note the worker joins only internal — it has no ports and no VIRTUAL_HOST.

See Deployment for the end-to-end bring-up procedure.