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 usage
Section titled “Redis usage”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:
REDIS_URL=redis://redis:6379Database migrations (Drizzle)
Section titled “Database migrations (Drizzle)”Migrations live in apps/api/drizzle/ and are applied with drizzle-kit. Run them
from the API workspace:
# from apps/apibun run db:migrate # apply pending migrationsbun run db:generate # generate a new migration from schema changesbun run db:check # validate the migration chainbun run db:studio # open Drizzle StudioInside 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:
podman compose exec -T api sh -c "cd /app/apps/api && bun run db:migrate"The migration chain
Section titled “The migration chain”| 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 |
Environment variables
Section titled “Environment variables”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.
Required
Section titled “Required”| 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 |
Auth, cookies & CORS
Section titled “Auth, cookies & CORS”| 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. |
Storage (R2 / S3)
Section titled “Storage (R2 / S3)”| 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) |
Redis, rate limit & stats
Section titled “Redis, rate limit & stats”| 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).
Compose services
Section titled “Compose services”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.