Configuration
A product API reads its settings from environment variables and checks all of them when it starts: an invalid or missing value stops the boot with a list of every problem at once (secrets are never printed). Use this page when you set up a new environment or when the API refuses to start.
Where the values live
| Environment | File |
|---|---|
| Development | apps/<product>/api/.env (gitignored; copy apps/<product>/api/.env.example) |
| Production | deploy/<product>/.env (gitignored; copy deploy/<product>/.env.example, see Deploy) |
| e2e tests | set by the e2e setup from the development file |
There is no root .env. An empty value (FOO=) counts as unset, so the default applies.
Two helpers:
configure-envskill (Claude Code and other agents): asks for each variable, generates the secrets, writes a development or production file and validates it.- Check a file without starting the API:
pnpm nx run core-api-env:check-env --file=apps/keelmint/api/.env.
Generate every secret with openssl rand -base64 32, one per variable, never reused across environments.
Server
| Variable | Required | Default | Rule |
|---|---|---|---|
NODE_ENV | no | development | development, production or test |
PORT | no | 3000 | integer 1–65535 |
APP_URL | yes | the public URL of the client (absolute http(s); a trailing / is dropped). Mailed links and Stripe redirects use it |
Node options
NODE_OPTIONS is read by Node itself, not by the API’s schema. The deploy’s api image starts node main.cjs, so set it in the compose environment: of the api service or in deploy/<product>/.env (it then reaches the migrate service too, which is harmless). Use it to raise Node’s 16 KB limit on request headers (NODE_OPTIONS=--max-http-header-size=65536) when a unit-scoped grant covers many units and every request answers 431; see Tenant data.
Database
| Variable | Required | Rule |
|---|---|---|
DATABASE_URL | yes | postgres:// or postgresql://. Jobs (pg-boss) use the same database |
Sessions and security
| Variable | Required | Default | Rule |
|---|---|---|---|
JWT_SECRET | yes | at least 32 characters; signs access tokens | |
JWT_ACCESS_TTL | no | 15m | duration with a unit: 900s, 15m, 1h, 7d |
REFRESH_TOKEN_TTL_DAYS | no | 7 | integer > 0; refresh tokens slide, so an active session stays signed in |
MAX_ACTIVE_SESSIONS_PER_USER | no | 10 | integer > 0; the oldest session is revoked beyond it |
THROTTLE_LIMIT | no | 5 | integer > 0; requests per IP per minute on the public auth routes |
TRUST_PROXY | no | false | false, a hop count (≤ 10), or proxy IPs/CIDRs/loopback; never true or /0. Set it behind a reverse proxy, or audit IPs and rate limits see the proxy |
CORS_ORIGIN | no | none | comma-separated http(s) origins, no path or trailing slash. Only needed when the client is served from another origin than the API; unset, cross-origin calls are refused |
Encryption keys
| Variable | Required | Rule |
|---|---|---|
JOBS_ENCRYPTION_KEY | yes | 32 random bytes, base64 (44 characters ending in =); encrypts job payloads (mail links) at rest |
TWO_FACTOR_ENCRYPTION_KEY | yes | same shape, a different key; encrypts TOTP secrets. Changing it invalidates every enrolled authenticator |
| Variable | Required | Default | Rule |
|---|---|---|---|
MAIL_FROM | yes | the sender, e.g. Acme <no-reply@acme.com> | |
SMTP_HOST | yes | non-blank (localhost for Mailpit) | |
SMTP_PORT | yes | integer 1–65535 (1025 for Mailpit) | |
SMTP_SECURE | no | false | true for implicit TLS (port 465) |
SMTP_USER | no | ||
SMTP_PASS | no | requires SMTP_USER |
SMTP providers, DNS (SPF, DKIM, DMARC) and the go-live checklist: Authentication.
Logging
| Variable | Required | Default | Rule |
|---|---|---|---|
LOG_LEVEL | no | info | fatal, error, warn, info, debug, trace or silent |
LOG_FORMAT | no | see rule | json or pretty; unset → pretty in development, else json |
Google login
| Variable | Required | Default | Rule |
|---|---|---|---|
GOOGLE_CLIENT_ID | no | with GOOGLE_CLIENT_SECRET, both or neither; turns on “Continue with Google” | |
GOOGLE_CLIENT_SECRET | no | see above | |
GOOGLE_REDIRECT_URI | no | ${APP_URL}/api/auth/google/callback | absolute http(s) URL ending in /api/auth/google/callback; only for a split-origin deploy |
GOOGLE_ISSUER | no | https://accounts.google.com | https, or http on localhost (the e2e suite’s fake Google) |
Setup: Google login.
Stripe billing
Needed when the product config has billing: a missing value stops the boot. One exception, so a fresh clone runs as it is: in development, leaving out all three keeps the API running with plans and limits, and only checkout, the Customer Portal and the webhook answer 409 BILLING_NOT_CONFIGURED until you add your Stripe test keys.
| Variable | Rule |
|---|---|
STRIPE_SECRET_KEY | sk_test_... or sk_live_..., with STRIPE_WEBHOOK_SECRET |
STRIPE_WEBHOOK_SECRET | whsec_..., the signing secret of your webhook endpoint |
STRIPE_PRICES | plan:month|year:priceId entries, comma-separated; every paid plan needs at least one |
STRIPE_API_BASE | tests only (the e2e suite’s fake Stripe); refused in production |
Setup: Billing.
File storage (helpdesk screenshots)
| Variable | Required | Default | Rule |
|---|---|---|---|
STORAGE_DRIVER | no | local | local or s3 |
STORAGE_DIR | production | tmp/storage | local only; in production a directory on a persistent volume |
S3_BUCKET | with s3 | ||
S3_REGION | with s3 | auto for Cloudflare R2 | |
S3_ENDPOINT | no | AWS | absolute http(s) URL (MinIO, R2) |
S3_FORCE_PATH_STYLE | no | false | true for MinIO |
S3_ACCESS_KEY_ID | no | with S3_SECRET_ACCESS_KEY, both or neither; neither uses the SDK’s default credentials | |
S3_SECRET_ACCESS_KEY | no | see above |
Give each product its own directory or bucket. More: Helpdesk.
Your product’s own variables
Pass a zod schema to core and it is validated together with the core variables, with the same error report:
CoreModule.forRoot({ ..., env: z.object({ ACME_API_KEY: z.string().min(1) }) })Read every value through ConfigService<Env, true>: it is already parsed and typed.