You're reading docs for Nextly Alpha. APIs may change between releases.

Configuration

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 .env

Canonical .env.example

The base template templates/base/.env.example ships the minimum required variables:

.env.example
# 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 32

Database

Source: packages/nextly/src/shared/lib/env.ts.

VariableRequiredDefaultDescription
DB_DIALECTYespostgresqlDatabase dialect: postgresql, mysql, or sqlite.
DATABASE_URLYes 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_MAXNo20Max connections in the pool. Min: 1.
DB_POOL_MINNo2Min connections in the pool. Min: 0.
DB_POOL_IDLE_TIMEOUTNo30000Idle connection timeout in milliseconds. Min: 1000.
DB_QUERY_TIMEOUTNo15000Query timeout in milliseconds. Min: 1000.
DB_HEALTHCHECK_INTERVAL_MSNo30000Health-check interval in milliseconds. Min: 1000.
DB_SNAKE_CASENofalseUse 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.db

Runtime

VariableRequiredDefaultDescription
NODE_ENVNodevelopmentRuntime environment: development, production, or test. Affects production-only checks below.

Development diagnostics

VariableRequiredDefaultDescription
NEXTLY_DEV_DIAGNOSTICSNo—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.

VariableRequiredDefaultDescription
NEXTLY_SECRETYes (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_PREVIOUSNo—Secrets you have RETIRED, newest first. Kept for READING only — nothing new is ever keyed with one. See "Rotating NEXTLY_SECRET" below.
NEXTLY_ALLOWED_ORIGINSSelf-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_TABLESNo—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-one

Two 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:

  • null is the unkeyed generation — what an install writes when it has no NEXTLY_SECRET at 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

VariableRequiredDefaultDescription
NEXT_PUBLIC_APP_URLYes (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_URLNohttp://localhost:3000/apiBase 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).

VariableRequiredDescription
S3_BUCKETYesBucket name.
AWS_REGIONYesAWS region (e.g. us-east-1). Use auto for Cloudflare R2.
AWS_ACCESS_KEY_IDYesAccess key ID.
AWS_SECRET_ACCESS_KEYYesSecret access key.
S3_ENDPOINTSometimesCustom endpoint URL. Required for R2 and MinIO.
S3_PUBLIC_URLNoPublic URL prefix (e.g. https://pub-xxxx.r2.dev for R2 or a CDN domain).
S3_FORCE_PATH_STYLENoSet true for MinIO and other path-style providers.
nextly.config.ts
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.

VariableRequiredDescription
BLOB_READ_WRITE_TOKENYesVercel Blob token from the Vercel dashboard. The adapter reads this directly when the token config option is omitted.

UploadThing — @nextlyhq/storage-uploadthing

VariableRequiredDescription
UPLOADTHING_TOKENYesUploadThing 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.

VariableRequiredDefaultDescription
SMTP_HOSTNo—SMTP server hostname (e.g. smtp.gmail.com).
SMTP_PORTNo587SMTP port. 587 for TLS, 465 for SSL. Range: 1–65535.
SMTP_USERNo—SMTP authentication username.
SMTP_PASSNo—SMTP authentication password.
SMTP_FROMNo—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.

VariableRequiredDefaultDescriptionRead by
TRUSTED_PROXY_IPSNoemptyComma-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.

VariableRequiredDefaultDescriptionRead by
PERMISSION_CACHE_ENABLEDNotrueSet to false or 0 to disable the hybrid permission cache.packages/nextly/src/services/lib/permissions.ts
PERMISSION_CACHE_TTL_SECONDSNo300Time-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_SIZENo10000In-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/cleanup

Debug

VariableRequiredDefaultDescriptionRead by
DEBUG_RBACNo—Set 1 to log detailed RBAC permission decisions.packages/nextly/src/services/lib/permissions.ts
DEBUG_CACHENo—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.

VariableDefaultDescription
DB_NAMEnextly_devPostgreSQL database name.
DB_USERpostgresPostgreSQL user.
DB_PASSWORD—PostgreSQL password. Override in production.
DB_PORT5432PostgreSQL host port.
ADMINER_PORT8080Adminer (database browser) UI port.
REDIS_PORT6379Redis cache port.
DRIZZLE_STUDIO_PORT4983Drizzle 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_xxxxxxxxxxxx

Development 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=true

Next steps