Environment Variables
Every environment variable Nextly reads, organized by category with required vs. optional indicators.
Nextly reads environment variables for database connections, authentication, storage, email, and other runtime settings. Most live in the central env schema (packages/nextly/src/shared/lib/env.ts); storage adapter env vars are per-adapter and read by the adapter packages directly.
Copy .env.example to .env and fill in values:
cp .env.example .envCanonical .env.example
The base template templates/base/.env.example ships the minimum required variables:
# Nextly Configuration
# Generated by create-nextly-app
# Database Configuration
DB_DIALECT=postgresql
DATABASE_URL=postgresql://user:password@localhost:5432/nextly_dev
# Authentication (REQUIRED)
# Generate with: openssl rand -base64 32
NEXTLY_SECRET=change-me-generate-a-secure-secret
# Application URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Development diagnostics (opt-in — see below)
# NEXTLY_DEV_DIAGNOSTICS=1
# Storage (per-adapter — see "Storage" section below)The create-nextly-app scaffolder fills in DB_DIALECT, DATABASE_URL, and an auto-generated 32-byte base64 NEXTLY_SECRET for you. For a production deployment, regenerate NEXTLY_SECRET with:
openssl rand -base64 32Database
Source: packages/nextly/src/shared/lib/env.ts.
| Variable | Required | Default | Description |
|---|---|---|---|
DB_DIALECT | Yes | postgresql | Database dialect: postgresql, mysql, or sqlite. |
DATABASE_URL | Yes for PostgreSQL and MySQL; for SQLite, for the CLI's database commands | — | Full database connection URL; for sqlite, a file: URL. The running app falls back to file:./data/nextly.db on SQLite, but the CLI commands that use the database (migrate, db:sync) require it on every dialect. Validated as a URL. |
DB_POOL_MAX | No | 20 | Max connections in the pool. Min: 1. |
DB_POOL_MIN | No | 2 | Min connections in the pool. Min: 0. |
DB_POOL_IDLE_TIMEOUT | No | 30000 | Idle connection timeout in milliseconds. Min: 1000. |
DB_QUERY_TIMEOUT | No | 15000 | Query timeout in milliseconds. Min: 1000. |
DB_HEALTHCHECK_INTERVAL_MS | No | 30000 | Health-check interval in milliseconds. Min: 1000. |
DB_SNAKE_CASE | No | false | Use snake_case for database column names instead of camelCase. |
Connection-string formats
# PostgreSQL (recommended for production)
DATABASE_URL=postgresql://user:password@localhost:5432/nextly_dev
# MySQL
DATABASE_URL=mysql://root:root@localhost:3306/nextly_dev
# SQLite (development / single-instance only)
DATABASE_URL=file:./dev.dbRuntime
| Variable | Required | Default | Description |
|---|---|---|---|
NODE_ENV | No | development | Runtime environment: development, production, or test. Affects production-only checks below. |
Development diagnostics
| Variable | Required | Default | Description |
|---|---|---|---|
NEXTLY_DEV_DIAGNOSTICS | No | — | Set to 1 to add a _devDiagnostics field to error responses, carrying the error's log context and the message of its underlying cause. Ignored unless NODE_ENV is development. |
An error response is deliberately generic: it carries a code, a public message and a request id, and withholds the log context and the underlying cause so a response cannot disclose driver output, table names or internal paths. That is right for a deployed app and unhelpful while you are building, where the detail you need is exactly what was withheld.
With this set, an error in development also carries:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred.",
"requestId": "req_01HQ...",
"_devDiagnostics": {
"logContext": { "collectionName": "posts", "entryId": "abc123" },
"cause": "duplicate key value violates unique constraint posts_slug_key",
"flattened": [{ "code": "CONFLICT", "logContext": { "field": "slug" } }]
}
}
}flattened lists errors that were rebuilt on the way to the boundary, which is
what makes an unexpected failure name itself instead of looking like every
other one.
Why it is opt-in rather than on by default. The gate requires two
independent signals: NODE_ENV must be development and this flag must be
1. The second exists precisely because the first can be wrong — nextly ships
pre-built and stays external to your build, so NODE_ENV is a runtime value a
deployment can carry by mistake.
A default that shipped in .env would be true in exactly that case, which is
the one the second signal guards against, collapsing two independent signals
back into one. So create-nextly-app writes the setting commented out with
this explanation: discoverable, and never enabled by a file that travels with
your app.
Uncomment it in your local .env — not in a file you deploy — and keep it out
of any environment where NODE_ENV might not be what you expect.
Authentication
Nextly uses a custom JWT auth system (access + refresh tokens, sessions, API keys, RBAC). There is no OAuth.
| Variable | Required | Default | Description |
|---|---|---|---|
NEXTLY_SECRET | Yes (production: ≥ 32 chars) | — | Secret used to sign JWTs and other crypto. Production startup throws if it is missing or shorter than 32 characters. Generate with openssl rand -base64 32. |
NEXTLY_SECRET_PREVIOUS | No | — | Secrets you have RETIRED, newest first. Kept for READING only — nothing new is ever keyed with one. See "Rotating NEXTLY_SECRET" below. |
NEXTLY_ALLOWED_ORIGINS | Self-hosted | — | Comma-separated list of additional origins allowed for CSRF validation (e.g. https://admin.example.com,https://staging.example.com). The origin the request arrives at is always allowed, but a self-hosted server (next start or the standalone build) takes that from the address it listens on, such as http://localhost:3000, not from your public URL. List the origin browsers use (https://example.com) whenever it differs from that address — behind a proxy, by IP or hostname, or in a container — or sign-in and plugin-route writes are refused with CSRF_FAILED. |
NEXTLY_ERASE_RETIRED_AUTH_TABLES | No | — | The retired accounts and sessions tables that are Nextly's, comma-separated (accounts, sessions or accounts,sessions). Deleting a user erases their rows only from a table named here, and only while it has the shape Nextly created. Nextly keeps no record that it created these tables, and the Auth.js adapters' tables share their names and shape, so name a table only when it is Nextly's. Unset, the rows stay, and the startup warning says so. See Production migrations. Needs the release after 0.0.2-alpha.66. |
Rotating NEXTLY_SECRET
Rotating the secret re-keys everything derived from it, and the values already written do not move. For most of them that fails loudly and is fine — a webhook signing secret that no longer decrypts is one an operator re-enters.
The email delivery log is the exception, because it fails quietly. Each row identifies its recipient by an HMAC of their address, so after a rotation an erasure request computes a digest none of the older rows carry, matches nothing, and reports success. "No rows matched" is also what a person with no mail looks like, so nothing distinguishes the two.
List the old secret in NEXTLY_SECRET_PREVIOUS and those rows stay reachable —
for lookups and for erasure — without becoming writable:
NEXTLY_SECRET=the-new-one
NEXTLY_SECRET_PREVIOUS=the-old-one,an-older-oneTwo spellings, because one of them cannot express every legal secret. The comma-separated form above is the ordinary one. Use the JSON-array form when a retired secret contains a comma, or when leading or trailing whitespace is part of the key — splitting or trimming those produces a key that was never used, which matches nothing and fails in the silent direction:
# Single quotes are REQUIRED. Without them the shell strips the inner double
# quotes and the value becomes `[old,with,commas, spaced ]`, which is not
# JSON — it falls back to comma splitting and derives four keys that were never
# used, so lookups and erasure silently miss the rows this is meant to reach.
NEXTLY_SECRET_PREVIOUS='["old,with,commas"," spaced "]'The JSON form also names two generations no string can:
nullis the unkeyed generation — what an install writes when it has noNEXTLY_SECRETat all, which is only possible outside production. Rows written then carry a plain SHA-256 digest that no HMAC reproduces, so enabling a secret later strands them unless you name that generation.""is a secret that really was the empty string, which is used as an HMAC key like any other.
# was unkeyed in development, now has a secret
NEXTLY_SECRET_PREVIOUS='[null]'An empty entry in the comma form is dropped rather than rejected: a trailing
comma is the likeliest way to write the list, and nothing about older, says a
second key was meant. Write '[""]' when you mean it — with the outer single quotes, for the same
reason.
Application URLs
| Variable | Required | Default | Description |
|---|---|---|---|
NEXT_PUBLIC_APP_URL | Yes (production) | — | Public-facing app URL. Used in client-side code and as the fallback for email-link baseUrl. Production startup throws if missing. |
API_BASE_URL | No | http://localhost:3000/api | Base URL for API routes. Validated as a URL. |
Storage (per-adapter)
The default storage backend is local disk under ./public/uploads/. No env vars are needed for default storage.
Cloud adapters are opt-in. The variables below are read by the adapter packages, not by the central env schema, so they only need to exist when you actually configure that adapter in nextly.config.ts. Nothing else (env-validation, etc.) checks for them.
S3 / S3-compatible — @nextlyhq/storage-s3
Works with AWS S3, Cloudflare R2, MinIO, and DigitalOcean Spaces. The plugin accepts these values directly; the env-var names below are the conventional ones used in our examples (and what your nextly.config.ts typically references with process.env.X).
| Variable | Required | Description |
|---|---|---|
S3_BUCKET | Yes | Bucket name. |
AWS_REGION | Yes | AWS region (e.g. us-east-1). Use auto for Cloudflare R2. |
AWS_ACCESS_KEY_ID | Yes | Access key ID. |
AWS_SECRET_ACCESS_KEY | Yes | Secret access key. |
S3_ENDPOINT | Sometimes | Custom endpoint URL. Required for R2 and MinIO. |
S3_PUBLIC_URL | No | Public URL prefix (e.g. https://pub-xxxx.r2.dev for R2 or a CDN domain). |
S3_FORCE_PATH_STYLE | No | Set true for MinIO and other path-style providers. |
import { s3Storage } from "@nextlyhq/storage-s3";
storage: [
s3Storage({
bucket: process.env.S3_BUCKET!,
region: process.env.AWS_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
publicUrl: process.env.S3_PUBLIC_URL,
collections: { media: true },
}),
];Vercel Blob — @nextlyhq/storage-vercel-blob
Recommended for Vercel deployments.
| Variable | Required | Description |
|---|---|---|
BLOB_READ_WRITE_TOKEN | Yes | Vercel Blob token from the Vercel dashboard. The adapter reads this directly when the token config option is omitted. |
UploadThing — @nextlyhq/storage-uploadthing
| Variable | Required | Description |
|---|---|---|
UPLOADTHING_TOKEN | Yes | UploadThing API token. The adapter reads this directly when the token config option is omitted. |
Email / SMTP
Source: packages/nextly/src/shared/lib/env.ts. Required only if you send emails (password resets, notifications). In production, if any SMTP variable is set, all four of SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_FROM must be set or the env validator throws.
| Variable | Required | Default | Description |
|---|---|---|---|
SMTP_HOST | No | — | SMTP server hostname (e.g. smtp.gmail.com). |
SMTP_PORT | No | 587 | SMTP port. 587 for TLS, 465 for SSL. Range: 1–65535. |
SMTP_USER | No | — | SMTP authentication username. |
SMTP_PASS | No | — | SMTP authentication password. |
SMTP_FROM | No | — | From address for outgoing emails. Validated as an email. |
For local development, the repo ships a Mailpit profile (docker compose --profile with-mailpit up -d mailpit) on host ports 1025 (SMTP) and 8025 (web UI) — see Email guide for details.
Security
These env vars are read by middleware and security helpers. None are part of the central env schema; they are read directly by the modules that use them.
| Variable | Required | Default | Description | Read by |
|---|---|---|---|---|
TRUSTED_PROXY_IPS | No | empty | Comma-separated CIDR list of trusted reverse-proxy IPs. Combined with security.trustProxy: true, controls which X-Forwarded-For values are honoured for client-IP resolution (rate limiting, refresh-token binding, audit logging). | packages/nextly/src/utils/get-trusted-client-ip.ts |
Performance — permission cache
Nextly's RBAC layer ships a hybrid (in-memory LRU + database) permission cache. These env vars tune it; none are part of the central env schema — they are read directly by the permission service.
| Variable | Required | Default | Description | Read by |
|---|---|---|---|---|
PERMISSION_CACHE_ENABLED | No | true | Set to false or 0 to disable the hybrid permission cache. | packages/nextly/src/services/lib/permissions.ts |
PERMISSION_CACHE_TTL_SECONDS | No | 300 | Time-to-live for database cache entries (seconds). This tier is shared between instances and the signal that retires it is held in memory, so until that signal is stored beside the rows this value is also the longest a revoked grant can survive on an instance that did not handle the change. | packages/nextly/src/services/lib/permissions.ts |
PERMISSION_CACHE_MEMORY_SIZE | No | 10000 | In-memory LRU cache size (number of entries). | packages/nextly/src/services/lib/permissions.ts |
The database permission cache benefits from periodic cleanup. A typical daily cron:
0 2 * * * curl -X POST http://localhost:3000/admin/api/auth/cache/cleanupDebug
| Variable | Required | Default | Description | Read by |
|---|---|---|---|---|
DEBUG_RBAC | No | — | Set 1 to log detailed RBAC permission decisions. | packages/nextly/src/services/lib/permissions.ts |
DEBUG_CACHE | No | — | Set 1 to log permission-cache hit/miss/eviction details. | packages/nextly/src/domains/auth/services/permission-cache-service.ts |
Docker development (optional)
Used by the bundled docker-compose.yml for local Postgres / Redis / Adminer / Drizzle Studio / Mailpit setup. Not needed when connecting to an existing database.
| Variable | Default | Description |
|---|---|---|
DB_NAME | nextly_dev | PostgreSQL database name. |
DB_USER | postgres | PostgreSQL user. |
DB_PASSWORD | — | PostgreSQL password. Override in production. |
DB_PORT | 5432 | PostgreSQL host port. |
ADMINER_PORT | 8080 | Adminer (database browser) UI port. |
REDIS_PORT | 6379 | Redis cache port. |
DRIZZLE_STUDIO_PORT | 4983 | Drizzle Studio port for the database GUI. |
Example .env files
Minimal production
# Runtime
NODE_ENV=production
# Database
DB_DIALECT=postgresql
DATABASE_URL=postgresql://user:password@db.example.com:5432/nextly_prod
# Auth
NEXTLY_SECRET=your-generated-secret-at-least-32-characters-long
NEXT_PUBLIC_APP_URL=https://your-domain.com
# Storage (Vercel Blob example)
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_xxxxxxxxxxxxDevelopment with S3-compatible storage (MinIO)
# Database
DB_DIALECT=postgresql
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nextly_dev
# Auth
NEXTLY_SECRET=dev-secret-minimum-32-characters-long-replace-in-production
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Storage (MinIO)
S3_BUCKET=nextly-dev
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
S3_ENDPOINT=http://localhost:9000
S3_FORCE_PATH_STYLE=trueNext steps
- Nextly config — configure storage and security from
nextly.config.ts - Database — choose and configure PostgreSQL, MySQL, or SQLite
- Authentication guide — auth-related env vars in depth
- Deployment guide — production environment-variable checklist