Environment Variables
Complete configuration reference for the VeriWorkly backend and frontend applications.
Environment Variables
Every configuration key across the VeriWorkly ecosystem. Defaults are the values the code falls back to when the variable is unset — they are not necessarily safe for production.
Production boots are validated
When NODE_ENV=production, the API runs a configuration validator in the master process before
any worker starts, and refuses to boot if a required value is missing or unsafe. See Production
requirements below.
Frontend applications
Set per application in apps/{app}/.env, or shared via the root .env.
| Variable | Required | Description | Example / default |
|---|---|---|---|
SITE_URL | Yes | The public URL of this application. | https://veriworkly.com |
NEXT_PUBLIC_BACKEND_URL | Yes | The API endpoint used by the browser. Inlined at build time. | http://localhost:8080/api/v1 |
BACKEND_INTERNAL_URL | No | The API address used for server-side rendering. In Docker, the service name. | http://api:8080/api/v1 |
AUTH_SECRET | Yes | Must match the backend value exactly. | — |
ADMIN_EMAIL | Yes | Server-only. Identifies the admin account; also unlocks the production-gated portfolio publish and checkout flows. Never exposed to the browser. | [email protected] |
ALLOWED_ORIGINS | No | Permitted origins for the docs platform's API proxy route. | https://api.veriworkly.com,https://veriworkly.com |
Portfolio-specific
| Variable | Description | Default |
|---|---|---|
PORTFOLIO_REVALIDATE_SECRET | Shared secret authorising the API's cache-revalidation callback after a publish. Must match the backend value. | — |
Backend (apps/server/.env)
Core runtime
| Variable | Description | Default |
|---|---|---|
NODE_ENV | development, production, or test. | development |
PORT | Port the API listens on. | 8080 |
TRUST_PROXY | Express trust-proxy setting. Accepts true/false, a hop count, or a CIDR/IP string. | false |
ALLOWED_ORIGINS | Comma-separated CORS allowlist. Each entry must be a full origin, not a bare port. | Ports 3000–3004 and 8080 on http://localhost |
LOG_LEVEL | debug, info, warn, or error. | info |
AUDIT_LOG_RETENTION_DAYS | How long AuditLog rows are kept. Production writes a row per 4xx/5xx, and the daily usage-metrics job is the only thing that prunes them. | 90 |
ANALYTICS_HASH_PEPPER | Pepper used to compute non-reversible SHA-256 visitor identifier hashes for privacy-safe telemetry. | veriworkly-analytics-pepper |
CLUSTERING_ENABLED | Run one worker per CPU core via throng. | true in production, false otherwise |
WEB_CONCURRENCY / SERVER_WORKERS | Explicit worker count, overriding the CPU-core default. | CPU count |
`TRUST_PROXY=true` is rejected in production
A bare true trusts every hop, which lets a client spoof X-Forwarded-For and defeat IP-based
rate limiting. Production requires an explicit hop count (e.g. 1 behind a single reverse
proxy) or a CIDR. The boot validator enforces this.
Database and cache
| Variable | Required | Description | Default |
|---|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string. | — |
REDIS_URL | Yes in practice | Redis connection string. Sessions, rate limiting, quotas, view buffers, and job locks all use it. | redis://localhost:6379 |
DB_POOL_MAX_TOTAL | No | Connection budget for the whole cluster, not per process. Set it to roughly 80% of the instance's real max_connections; the per-worker pool is derived by dividing this by the worker count. | 80 in production, 10 otherwise |
DB_POOL_MAX | No | Explicit per-process override. Leave unset unless PgBouncer sits in front in transaction mode — a stale value here is how you overcommit the database. | derived from DB_POOL_MAX_TOTAL |
DB_POOL_IDLE_TIMEOUT_MS | No | Database client idle timeout in milliseconds before connection is closed. | 15000 |
DB_POOL_CONNECTION_TIMEOUT_MS | No | Maximum wait time in milliseconds to acquire a connection from the pool before timing out. | 30000 |
DB_POOL_STATEMENT_TIMEOUT_MS | No | PostgreSQL server query execution statement timeout in milliseconds. | 30000 |
The pool budget is cluster-wide
With clustering on, each worker opens its own pool, so what must fit inside Postgres'
max_connections is (pool size × workers). The API logs a warning at boot if DB_POOL_MAX × workers exceeds DB_POOL_MAX_TOTAL.
Authentication
| Variable | Description | Default |
|---|---|---|
AUTH_SECRET | Session signing secret. Must match every frontend. | dev-auth-secret |
AUTH_BASE_URL | Base URL for auth callbacks. Must be HTTPS in production. | http://localhost:8080 |
AUTH_COOKIE_DOMAIN | Cookie scope. Set to .yourdomain.com to enable cross-subdomain sessions. | unset (cross-subdomain disabled) |
AUTH_IP_ADDRESS_HEADERS | Comma-separated headers consulted for the client IP. | x-client-ip,x-forwarded-for,x-real-ip,cf-connecting-ip |
AUTH_SESSION_TTL_SECONDS | Session lifetime. | 2592000 (30 days) |
AUTH_SESSION_RESET_TTL_ON_USE | How often an active session's TTL is refreshed. | 86400 (24 hours) |
AUTH_SESSION_CACHE_ENABLED | Enable the signed cookie session cache. | true in production |
AUTH_SESSION_CACHE_MAX_AGE_SECONDS | Cookie cache lifetime. | 900 |
AUTH_OTP_TTL_SECONDS | Email OTP validity window. | 300 (5 minutes) |
AUTH_OTP_ALLOWED_ATTEMPTS | Failed OTP attempts before invalidation. | 3 |
ADMIN_EMAIL | The admin account, auto-provisioned on boot. Always required. | — |
OAuth providers
Each provider is registered only if both of its values are set.
| Variable | Provider |
|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | GitHub |
LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET |
API key security
| Variable | Description | Default |
|---|---|---|
API_KEY_HASH_SECRET | HMAC secret used to hash API keys. Must be distinct from AUTH_SECRET in production. | falls back to AUTH_SECRET |
API_KEY_AUTH_CACHE_TTL_SECONDS | Redis cache TTL for successful key lookups. | 300 |
API_KEY_LAST_USED_TOUCH_INTERVAL_SECONDS | Minimum interval between lastUsed writes. | 300 |
API_KEY_DEFAULT_RATE_LIMIT | Per-key request limit (per 15 minutes) when none is specified. | 20 |
API_KEY_MAX_RATE_LIMIT | Ceiling a caller may request for a single key. Must stay above the default — when the two were equal, every requested limit was clamped back down and a key's rate limit could only ever be lowered. | 600 |
API_KEY_DEFAULT_SCOPES | Comma-separated default scopes. | user:read |
API_KEY_DEFAULT_LIFETIME_DAYS | Default key lifetime. | 365 |
| Variable | Description | Default |
|---|---|---|
AUTH_EMAIL_PROVIDER | smtp or console. console logs instead of sending, and is rejected in production. | console |
AUTH_EMAIL_FROM | Sender address and display name. | VeriWorkly <[email protected]> |
AUTH_SMTP_HOST | SMTP hostname. Required when the provider is smtp. | — |
AUTH_SMTP_PORT | SMTP port. | 587 |
AUTH_SMTP_SECURE | Use implicit TLS. | false |
AUTH_SMTP_USER / AUTH_SMTP_PASS | SMTP credentials. Both required when the provider is smtp. | — |
Rate limiting
| Variable | Description | Default |
|---|---|---|
RATE_LIMIT_WINDOW_MS | Per-route window. | 900000 (15 minutes) |
RATE_LIMIT_MAX_REQUESTS | Per-route limit per window. | 100 |
AUTH_RATE_LIMIT_WINDOW_MS | Window for /api/v1/auth/*. | 60000 |
AUTH_RATE_LIMIT_MAX_REQUESTS | Limit for /api/v1/auth/*. | 20 |
GLOBAL_RATE_LIMIT_WINDOW_MS | Window for the coarse per-IP ceiling underneath the per-route buckets. | 900000 |
GLOBAL_RATE_LIMIT_MAX_REQUESTS | That ceiling. Should sit well above any real single-user session. | 1000 |
MAX_MEMORY_ENTRIES | Cap on the in-memory rate-limit map used when Redis is unavailable. Oldest entries are evicted first. | 15000 |
Why there are two IP buckets
The per-route limits are keyed by (method, path, IP), so they bound each endpoint independently
and never bound a client's total load — across ~70 endpoints one IP could legitimately issue many
times the headline number. GLOBAL_RATE_LIMIT_* is the ceiling underneath them.
Several routes carry hard-coded limits that ignore all of the above: share-password verification (3 / 5 min), the public contact form (5 / hour), and usage-metric ingestion (15 / min). The Dodo billing webhook is exempt from IP rate limiting entirely — it is authenticated by HMAC signature, and throttling a provider retry storm would silently diverge payment state from reality.
AI
| Variable | Description | Default |
|---|---|---|
AI_API_KEY | Provider credential. Required in production. | — |
AI_BASE_URL | OpenAI-compatible endpoint used to route requests. | — |
AI_TIMEOUT_MS | Per-request timeout. | 120000 |
AI_RATE_LIMIT_WINDOW_MS | Window for /api/v1/ai/*. | 60000 |
AI_RATE_LIMIT_MAX_REQUESTS | Limit for /api/v1/ai/*. | 20 |
AI_STANDARD_MODEL / AI_EXPERT_MODEL | Model IDs. Read only indirectly — a policy names them as env:AI_STANDARD_MODEL. | — |
SITE_URL | Sent as the referer/attribution header to the upstream AI provider. | — |
Private AI policies
There are three independent private policies, each with its own _PATH / _JSON pair. Point
_PATH at a mounted, gitignored file, or inject the same JSON inline through _JSON; _JSON wins
when both are set. None of them is safe to commit.
| Variable pair | Governs |
|---|---|
AI_ACTIONS_POLICY_PATH / AI_ACTIONS_POLICY_JSON | General AI writing actions (rewrite, generate, tailor) — model routing, prompts, and credit costs. |
ATS_AI_POLICY_PATH / ATS_AI_POLICY_JSON | The ATS AI-analysis layer (/ats/analyze insights and /ats/convert-resume) — prompts and pricing. |
ATS_ENGINE_POLICY_PATH / ATS_ENGINE_POLICY_JSON | The deterministic ATS scoring engine — rule weights, regex patterns, and the keyword dictionary. |
Why these are private, and what happens when they are missing
Per-action prompts, model selection, token limits, and credit costs live in policy files
rather than application code; the scoring engine's weights and keyword dictionary in particular
must stay private, or the ATS score becomes gameable. A request needing an unconfigured policy
fails with 503 <policy name> is not configured. In production the API additionally validates at
boot that the actions policy defines both a standard and an expert tier for every action, and
refuses to start otherwise.
Renamed from `AI_PRIVATE_CONFIG_*`
The single combined AI_PRIVATE_CONFIG_PATH / AI_PRIVATE_CONFIG_JSON pair was split into the
three policies above. Those two names are no longer read by anything — a deployment still setting
them has no AI policy configured at all.
Billing (Dodo Payments)
| Variable | Description | Default |
|---|---|---|
DODO_PAYMENTS_API_KEY | API credential. Required in production. | — |
DODO_PAYMENTS_WEBHOOK_SECRET | Webhook signature secret. Required in production. | — |
DODO_PAYMENTS_ENVIRONMENT | test_mode or live_mode. | test_mode |
DODO_PAYMENTS_PORTFOLIO_PRO_{SEVEN_DAY,MONTHLY,ANNUAL}_PRODUCT_ID | Creator Pro product IDs. | — |
DODO_PAYMENTS_AI_CREDITS_{MONTHLY,ANNUAL}_PRODUCT_ID | AI Standalone product IDs. | — |
DODO_PAYMENTS_BUNDLE_{ONE_DAY,SEVEN_DAY,MONTHLY,ANNUAL}_PRODUCT_ID | Job Hunter Bundle product IDs, including the time-boxed passes. | — |
DODO_PAYMENTS_CREDIT_PACK_{250,500}_PRODUCT_ID | One-time credit pack product IDs. | — |
DODO_PAYMENTS_CHECKOUT_RETURN_URL | Post-purchase redirect. | http://localhost:3001/billing?checkout=complete |
DODO_PAYMENTS_CHECKOUT_CANCEL_URL | Cancelled-checkout redirect. | http://localhost:3001/billing?checkout=cancelled |
DODO_PAYMENTS_PORTAL_RETURN_URL | Billing-portal return URL. | http://localhost:3001/billing |
A plan or interval whose product ID is unset is reported as not configured and cannot be purchased — this is how the public catalog decides which intervals to offer.
Object storage (Cloudflare R2)
All five are required in production — portfolio image uploads fail without them.
| Variable | Description |
|---|---|
R2_ENDPOINT | S3-compatible endpoint. |
R2_BUCKET | Bucket name. |
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY | Credentials. |
R2_PUBLIC_BASE_URL | Public base URL served to browsers. Trailing slashes are stripped. |
Portfolio
| Variable | Description | Default |
|---|---|---|
PORTFOLIO_URL | Public URL of the portfolio application. | http://localhost:3004 |
PORTFOLIO_GRACE_DAYS | Days a lapsed portfolio stays live before suspension. | 7 |
PORTFOLIO_REVALIDATE_SECRET | Shared secret for the publish-time cache revalidation callback. | dev-revalidate-secret |
Growth programs
| Variable | Description | Default |
|---|---|---|
AFFILIATE_PROGRAM_ENABLED | Enables the affiliate API and dashboard. | enabled outside production, disabled in production |
AMBASSADOR_PROGRAM_ENABLED | Enables the ambassador API and dashboard. | enabled outside production, disabled in production |
Both are read once at server boot. Changing either requires a restart. When a flag is off, the
corresponding routes return 503 and Studio shows a "Coming Soon" screen.
GitHub sync
| Variable | Description | Default |
|---|---|---|
GITHUB_SYNC_ENABLED | Enables the scheduled repository statistics sync. | true |
GITHUB_TOKEN | Personal access token with repository read scope. Also used by paid GitHub profile imports. | — |
GITHUB_OWNER / GITHUB_REPO | Target repository. | — |
GITHUB_PROJECT_URL | Project board URL surfaced in the roadmap UI. | — |
GITHUB_SYNC_CRON | Sync schedule. | 0 0,12 * * * |
GITHUB_SYNC_TIMEZONE | Cron timezone. | UTC |
INTERNAL_SYNC_API_KEY | Shared secret authorising the internal manual-sync endpoint. | — |
Changelog sync
| Variable | Description | Default |
|---|---|---|
CHANGELOG_RELEASE_SYNC_ENABLED | Imports missing GitHub Releases into the changelog. | true |
CHANGELOG_RELEASE_SYNC_CRON | Sync schedule. | 0 6 * * * |
CHANGELOG_RELEASE_SYNC_TIMEZONE | Cron timezone. | UTC |
CHANGELOG_RELEASE_SYNC_MIN_INTERVAL_SECONDS | Minimum gap between two startup syncs. Restarts within this window skip the GitHub scan entirely; the cron is unaffected. | 21600 |
Usage metrics
| Variable | Description | Default |
|---|---|---|
USAGE_METRICS_FLUSH_CRON | Flush schedule. | 10 0 * * * |
USAGE_METRICS_FLUSH_TIMEZONE | Cron timezone. | UTC |
USAGE_METRICS_REDIS_RETENTION_DAYS | How long buffered counters are retained in Redis. | 10 |
Cache TTLs (seconds)
| Variable | Default |
|---|---|
ROADMAP_CACHE_TTL_SECONDS | 2592000 (30 days) |
ROADMAP_STATS_CACHE_TTL_SECONDS | 2592000 |
ROADMAP_TAGS_CACHE_TTL_SECONDS | 2592000 |
CHANGELOG_CACHE_TTL_SECONDS | 2592000 |
GITHUB_STATS_CACHE_TTL_SECONDS | 43200 (12 hours) |
Production requirements
The boot validator enforces all of the following when NODE_ENV=production. Any failure aborts
startup with a message naming the variable.
Always checked (all environments)
ADMIN_EMAIL,AUTH_SECRET, andAUTH_BASE_URLare set.- Every numeric auth and API-key setting parses to a positive integer.
- When
AUTH_EMAIL_PROVIDER=smtp, the SMTP host, user, and password are all set.
Production only
AUTH_EMAIL_PROVIDERissmtp— theconsoleprovider does not send mail.AUTH_SECRETis not the development defaultdev-auth-secret.AUTH_BASE_URLstarts withhttps://.TRUST_PROXYis not a baretrue— use an explicit hop count or CIDR.API_KEY_HASH_SECRETis explicitly set and differs fromAUTH_SECRET.AI_API_KEYis set, and the AI policy defines both modes for every action.DODO_PAYMENTS_API_KEYandDODO_PAYMENTS_WEBHOOK_SECRETare set.- All five
R2_*variables are set.