Skip to content

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

EnvironmentFile
Developmentapps/<product>/api/.env (gitignored; copy apps/<product>/api/.env.example)
Productiondeploy/<product>/.env (gitignored; copy deploy/<product>/.env.example, see Deploy)
e2e testsset 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-env skill (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

VariableRequiredDefaultRule
NODE_ENVnodevelopmentdevelopment, production or test
PORTno3000integer 1–65535
APP_URLyesthe 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

VariableRequiredRule
DATABASE_URLyespostgres:// or postgresql://. Jobs (pg-boss) use the same database

Sessions and security

VariableRequiredDefaultRule
JWT_SECRETyesat least 32 characters; signs access tokens
JWT_ACCESS_TTLno15mduration with a unit: 900s, 15m, 1h, 7d
REFRESH_TOKEN_TTL_DAYSno7integer > 0; refresh tokens slide, so an active session stays signed in
MAX_ACTIVE_SESSIONS_PER_USERno10integer > 0; the oldest session is revoked beyond it
THROTTLE_LIMITno5integer > 0; requests per IP per minute on the public auth routes
TRUST_PROXYnofalsefalse, 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_ORIGINnononecomma-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

VariableRequiredRule
JOBS_ENCRYPTION_KEYyes32 random bytes, base64 (44 characters ending in =); encrypts job payloads (mail links) at rest
TWO_FACTOR_ENCRYPTION_KEYyessame shape, a different key; encrypts TOTP secrets. Changing it invalidates every enrolled authenticator

Mail

VariableRequiredDefaultRule
MAIL_FROMyesthe sender, e.g. Acme <no-reply@acme.com>
SMTP_HOSTyesnon-blank (localhost for Mailpit)
SMTP_PORTyesinteger 1–65535 (1025 for Mailpit)
SMTP_SECUREnofalsetrue for implicit TLS (port 465)
SMTP_USERno
SMTP_PASSnorequires SMTP_USER

SMTP providers, DNS (SPF, DKIM, DMARC) and the go-live checklist: Authentication.

Logging

VariableRequiredDefaultRule
LOG_LEVELnoinfofatal, error, warn, info, debug, trace or silent
LOG_FORMATnosee rulejson or pretty; unset → pretty in development, else json

Google login

VariableRequiredDefaultRule
GOOGLE_CLIENT_IDnowith GOOGLE_CLIENT_SECRET, both or neither; turns on “Continue with Google”
GOOGLE_CLIENT_SECRETnosee above
GOOGLE_REDIRECT_URIno${APP_URL}/api/auth/google/callbackabsolute http(s) URL ending in /api/auth/google/callback; only for a split-origin deploy
GOOGLE_ISSUERnohttps://accounts.google.comhttps, 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.

VariableRule
STRIPE_SECRET_KEYsk_test_... or sk_live_..., with STRIPE_WEBHOOK_SECRET
STRIPE_WEBHOOK_SECRETwhsec_..., the signing secret of your webhook endpoint
STRIPE_PRICESplan:month|year:priceId entries, comma-separated; every paid plan needs at least one
STRIPE_API_BASEtests only (the e2e suite’s fake Stripe); refused in production

Setup: Billing.

File storage (helpdesk screenshots)

VariableRequiredDefaultRule
STORAGE_DRIVERnolocallocal or s3
STORAGE_DIRproductiontmp/storagelocal only; in production a directory on a persistent volume
S3_BUCKETwith s3
S3_REGIONwith s3auto for Cloudflare R2
S3_ENDPOINTnoAWSabsolute http(s) URL (MinIO, R2)
S3_FORCE_PATH_STYLEnofalsetrue for MinIO
S3_ACCESS_KEY_IDnowith S3_SECRET_ACCESS_KEY, both or neither; neither uses the SDK’s default credentials
S3_SECRET_ACCESS_KEYnosee 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.