- Configuration is environment variables, read only by
apps/server/config/*.ts(one module per plugin) and assembled inapps/server/orthacms.config.ts. - Every read goes through the readers in
@orthacms/utils-server(readEnv,readFlag,readPositiveInt, …). Plugins never touchprocess.env. - The
orthacmsCLI loads<app root>/.env. Variables already exported in the shell win over the file.
How values are read
A value the readers refuse stops the server at load, naming the variable.
- Empty means unset.
FOO=falls back to the default. - Numbers are plain positive integers.
1e9,0x20,-1and0are refused — exceptOUTBOX_RETENTION_DAYSandTRUST_PROXY, where0means something. - Flags are true only for the literal
true.1,yes,onread as false.
// apps/server/config/*.ts — how a setting is read
import { readEnv, readFlag, readPositiveInt, requireEnv } from '@orthacms/utils-server';
requireEnv('DATABASE_URL', 'Set it in your .env.'); // throws at load if unset
readPositiveInt('SESSION_TTL_SECONDS', 604800); // default when unset
readFlag('MCP_ENABLED', false); // 'true' → true, anything else → false
readEnv('SSO_PUBLIC_BASE_URL'); // string | undefined (trimmed; '' → undefined)A production .env
NODE_ENV=production
DATABASE_URL=postgresql://orthacms:<password>@db.internal:5432/orthacms
PORT=3000
TRUST_PROXY=1 # one reverse proxy in front
ALLOWED_ORIGINS=https://cms.example.com # the origin the admin is served from
ORTHACMS_ROOT_ADMIN_EMAIL=ops@example.com
ORTHACMS_ROOT_ADMIN_PASSWORD=<long random>
MEDIA_LOCAL_ROOT=/var/lib/orthacms/media # a persistent volumeCore
| Variable | Default | What it does |
|---|---|---|
DATABASE_URL | — | PostgreSQL connection string. Required. |
NODE_ENV | unset | production marks the session cookie Secure and turns the API reference off. Only development, test, production are accepted. |
PORT | 3000 | Port the API listens on. |
ADMIN_PORT | 4200 | Port the admin dev server listens on; also the default ALLOWED_ORIGINS. |
API_PORT | PORT | Monorepo only: where the admin dev server proxies /api. A generated app’s Vite config uses PORT. |
API_DOCS | on outside production | Serve the Scalar reference on /reference, and GraphiQL when GraphQL is registered. |
TRUST_PROXY | unset | How many reverse proxies sit in front. See below. |
MAX_REQUEST_BODY | 1mb | Largest JSON body. Over it: a bare 413 from the parser. Uploads are multipart and unaffected. |
OUTBOX_RETENTION_DAYS | 30 | Days a delivered outbox event is kept. 0 keeps them forever. Pending and parked rows are never deleted by age. See what grows. |
TRUST_PROXY is required behind a load balancer
Unset, Express ignores X-Forwarded-For and every request reports the proxy’s address:
- the login rate limit becomes one bucket for everyone — one attacker locks out all users;
- every session records the proxy’s IP.
Values: a hop count (1 — preferred, unforgeable), true (trust the whole chain; only on a closed network), or an Express subnet/preset such as loopback or 10.0.0.0/8. Leave it unset only when the server is exposed directly.
Identity
| Variable | Default | What it does |
|---|---|---|
ALLOWED_ORIGINS | http://localhost:<ADMIN_PORT> | Comma-separated origins allowed to make state-changing calls. Another browser origin gets 403 Origin not allowed. |
SESSION_TTL_SECONDS | 604800 | Session lifetime (7 days). |
INVITE_TTL_SECONDS | 604800 | Invitation lifetime. |
RESET_TTL_SECONDS | 3600 | Password-reset lifetime (1 hour). |
LOGIN_RATE_LIMIT | 10 | Login attempts per window. |
LOGIN_RATE_LIMIT_TTL_SECONDS | 60 | The window. |
No signing secrets
Sessions and invite/reset tokens are opaque random values checked against a row. There is nothing to rotate. SESSION_SECRET and TOKEN_SECRET were removed in 0.4.0 — delete them from .env.
Root administrator
| Variable | What it does |
|---|---|
ORTHACMS_ROOT_ADMIN_EMAIL | Provisions an active admin on boot. Unset skips it. |
ORTHACMS_ROOT_ADMIN_PASSWORD | Its password, bcrypt-hashed. Required once the email is set — the boot refuses otherwise. |
ORTHACMS_ROOT_ADMIN_NAME | Display name. Optional. |
- Idempotent and non-destructive: an existing account keeps its password, role and status.
- It is the one account
SSO_ALLOW_PASSWORD_LOGIN=falsenever locks out.
Single sign-on
| Variable | Default | What it does |
|---|---|---|
SSO_PUBLIC_BASE_URL | first allowed origin | The origin browsers reach the API on. Builds the redirect_uri. |
SSO_REQUEST_TTL_SECONDS | 600 | How long one sign-in attempt stays live. |
SSO_PROVISION_DOMAINS | unset (off) | Comma-separated domains an account may be created for. Reference server only. |
SSO_PROVISION_ROLE | viewer | Role for a provisioned account. Reference server only. |
SSO_ALLOW_PASSWORD_LOGIN | true | false makes SSO the only way in (except the root admin). Anything but false keeps passwords. Reference server only. |
SSO_SESSION_TTL_SECONDS | SESSION_TTL_SECONDS | Shorter lifetime for SSO sessions. Reference server only. |
“Reference server only” means the product repository’s apps/server/config/identity.ts reads it; a generated app’s config/identity.ts does not — add the line there (the settings map to IdentityPluginConfig.sso). Per-provider SSO_OIDC_*, SSO_GITHUB_* and SSO_SAML_* are on single sign-on.
Media
Only the block for the backend plugins.ts constructs is read. MEDIA_PROVIDER does not exist.
| Variable | Default | What it does |
|---|---|---|
MEDIA_MAX_UPLOAD_BYTES | 52428800 | Max upload (50 MB). |
MEDIA_DIRECT_SERVE | off | signed-url redirects downloads to the backend. Any other value is off. |
MEDIA_DIRECT_SERVE_TTL_SECONDS | 300 | Signed-URL lifetime. |
MEDIA_LOCAL_ROOT | ./.storage/media | Local disk root. Ephemeral in a container — use a volume. |
MEDIA_S3_BUCKET | — | Required by S3. |
MEDIA_S3_REGION | auto | auto suits R2; AWS needs its real region. |
MEDIA_S3_ENDPOINT | — | Omit for AWS; set for R2, MinIO, Spaces, B2… |
MEDIA_S3_FORCE_PATH_STYLE | false | Path-style URLs (MinIO). |
MEDIA_S3_ACCESS_KEY_ID / MEDIA_S3_SECRET_ACCESS_KEY | — | Set both or neither (neither = instance role / IRSA). |
MEDIA_AZURE_CONTAINER | — | Required by Azure. |
MEDIA_AZURE_CONNECTION_STRING | — | Required by Azure. |
MEDIA_GCS_BUCKET | — | Required by GCS. |
MEDIA_GCS_PROJECT_ID | — | Optional; ADC otherwise. |
MEDIA_GCS_KEY_FILE | — | Service-account key file path. |
MEDIA_GCS_SIGN_WITH_IAM | false | Sign URLs through IAM. |
BLOB_READ_WRITE_TOKEN | — | Vercel Blob, when running off Vercel. |
The product repository’s own server is wired to local disk and reads only MEDIA_LOCAL_ROOT. A generated app reads its chosen backend’s block from config/media-storage.ts. Per-backend snippets: media storage.
Copilot
| Variable | Default | What it does |
|---|---|---|
COPILOT_ENABLED | false | Global kill switch. false unregisters every /api/copilot route. |
COPILOT_MAX_OUTPUT_TOKENS | 8192 | Ceiling on one model response. |
COPILOT_MAX_STEPS | 30 | Model calls per run. Reference server only. |
COPILOT_WALL_CLOCK_MS | 300000 | Wall clock per run. Reference server only. |
COPILOT_MAX_TOTAL_TOKENS | 400000 | Tokens across a run. Reference server only. |
COPILOT_USER_DAILY_TOKENS | — (no limit) | Tokens one user may spend in one workspace per day (UTC). Reference server only. |
COPILOT_WORKSPACE_DAILY_TOKENS | — (no limit) | Tokens a whole workspace may spend per day (UTC). Reference server only. |
COPILOT_RUNS_PER_MINUTE | — (no limit) | Runs one user may start per minute, across workspaces. Reference server only. |
COPILOT_MAX_CONCURRENT_RUNS | — (no limit) | Runs one user may have in flight at once, across workspaces. Reference server only. |
ANTHROPIC_API_KEY | — | Setting it registers the Claude backend. |
ANTHROPIC_BASE_URL | — | A gateway or regional endpoint. Reference server only. |
COPILOT_ANTHROPIC_MODELS | a built-in list | Comma-separated model ids. The first is the default. |
COPILOT_OPENAI_BASE_URL | — | Setting it registers the OpenAI-compatible backend (e.g. http://localhost:11434/v1 for Ollama). |
COPILOT_OPENAI_MODELS | llama3.1 | Comma-separated. The first is the default. |
COPILOT_OPENAI_API_KEY | — | Empty for a local runtime with no auth. |
COPILOT_ENABLED=truewith no backend configured fails at boot.- With both backends configured, Claude is registered first and serves a run that names no provider.
- Move the three run ceilings together — see setting up the copilot.
- The four usage limits (since 0.11.0) are each off when empty, and
0fails at boot. They are counted in Postgres, so they hold across instances and survive a restart; a refused run is a429. See usage limits.
COPILOT_ENABLED=true
ANTHROPIC_API_KEY=sk-ant-…
# or a local model:
# COPILOT_OPENAI_BASE_URL=http://localhost:11434/v1MCP
| Variable | Default | What it does |
|---|---|---|
MCP_ENABLED | false | Mounts POST /api/v1/mcp. |
MCP_CALL_TIMEOUT_MS | 30000 | Ceiling on one tools/call. |
MCP_MAX_RESULT_BYTES | 4194304 | Ceiling on one result (4 MB). |
Once on, any full-scope token can drive content CRUD — publish and delete included, unless a protection rule refuses tokens. See the MCP endpoint.
GraphQL
Read only when the GraphQL plugin is registered.
| Variable | Default | What it bounds |
|---|---|---|
GRAPHQL_MAX_DEPTH | 8 | Nesting depth of one query. |
GRAPHQL_MAX_COMPLEXITY | 1000 | The computed cost budget. |
GRAPHQL_MAX_FIELDS | 500 | Fields one query may select. |
GRAPHQL_MAX_QUERY_LENGTH | 16384 | Bytes of query text. |
GRAPHQL_SCHEMA_CACHE_TTL_MS | 60000 | How long a built schema is reused. |
Schema builder
The admin’s Content Model page always shows the model. These decide whether it may edit it. See the schema builder.
| Variable | Default | What it does |
|---|---|---|
SCHEMA_BUILDER | false | Turns editing on. Still refused with NODE_ENV=production and without the source tree. |
SCHEMA_BUILDER_ROOT | apps/server | The server app’s folder, resolved against the directory the server starts in. |
SCHEMA_BUILDER_RESTART | watch | manual when nothing restarts the server on a change to src/ — the page then asks you to. |
Set SCHEMA_BUILDER=true on your own machine only. On a deployed server it
changes nothing: production wins over the flag.
Segments
| Variable | Default | What it does |
|---|---|---|
SEGMENTS_READER_TAGS_HEADER | x-reader-tags | The request header the public API reads the reader’s tags from. |
New in 0.7.0: config/segments.ts wires headerSegmentResolver by default, so a caller names the reader with a comma-separated header. No header = the anonymous reader. Nothing changes until a segment exists.
curl https://cms.example.com/api/v1/content/article \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Reader-Tags: premium, eu'Send it only from the backend that holds the token, never from a browser. See segments.
Webhooks
Endpoints, secrets and filters live in the database (admin → Webhooks). These variables tune the sender.
| Variable | Default | What it does |
|---|---|---|
WEBHOOKS_DELIVERY_INTERVAL | 2000 | How often the sender polls the queue, in ms. |
WEBHOOKS_TIMEOUT | 10000 | Per-request timeout, in ms. |
WEBHOOKS_RETENTION_DAYS | 30 | Days a finished delivery stays in the log. |
WEBHOOKS_ALLOW_INSECURE_URLS | false | Permit http://. Local development only. |
WEBHOOKS_ALLOW_PRIVATE_NETWORKS | false | Permit loopback, link-local and RFC 1918 targets. |
The allow flags widen what a typed URL can reach
A webhook is the server requesting an address a user typed — the shape of every SSRF. 169.254.169.254 (cloud metadata) is refused by default. Never set WEBHOOKS_ALLOW_PRIVATE_NETWORKS on an internet-facing deployment.
Zero is refused from the environment. To queue without sending from a process (deliveryIntervalMs: 0) or keep the log forever (retentionDays: 0), set it in config/webhooks.ts:
// apps/server/config/webhooks.ts — web nodes enqueue, one sender process sends.
// SENDER is your own variable; any way of telling processes apart works.
deliveryIntervalMs: readFlag('SENDER', false)
? readPositiveInt('WEBHOOKS_DELIVERY_INTERVAL', 2_000)
: 0,Senders claim rows with FOR UPDATE SKIP LOCKED, so several senders just send faster.
Unset MAIL_PROVIDER is a complete configuration: nothing is sent, invite/reset routes return the raw link, and self-service password recovery is off (its route is a 404). See sending mail.
| Variable | Default | What it does |
|---|---|---|
MAIL_PROVIDER | unset | smtp. The reference server also accepts console; a generated app refuses anything but smtp. |
APP_URL | — | Required with a backend. Absolute URL every link is built from. |
MAIL_FROM | — | Required with a backend. no-reply@… or Name <address>. |
MAIL_REPLY_TO | unset | Reply-to, when not MAIL_FROM. |
MAIL_PRODUCT_NAME | Ortha CMS | What the copy calls the product. |
MAIL_REVEAL_LINKS | false | Also return the raw link. Debugging only. |
MAIL_DELIVERY_INTERVAL_MS | 2000 | Queue poll interval, in ms. |
MAIL_MAX_ATTEMPTS | 5 | Attempts before a message is a dead letter. |
SMTP_HOST | — | Required with smtp. |
SMTP_PORT | 587 | 465 for implicit TLS. |
SMTP_SECURE | port === 465 | TLS from the first byte. Leave empty on 587. |
SMTP_USER / SMTP_PASSWORD | unset | Relay credentials. |
SMTP_REQUIRE_TLS | false | Refuse a relay that cannot upgrade to TLS. |
SMTP_CONNECTION_TIMEOUT_MS | transport default | Connection timeout. |
APP_URL is not taken from the request
It is configured, never read from the caller-controlled Host header — otherwise an attacker could turn your invitation into a phishing link.
Releasing
NPM_TOKEN and GITHUB_TOKEN are read only by the monorepo’s release script.
Configuration that is not environment variables
Lists and objects live in code:
| Setting | Where |
|---|---|
| Locales, and what to do with rows in a removed locale | config/i18n.ts — locales, orphanedLocales |
| Which plugins run, and their order | plugins.ts |
| Content types | One file each under src/content/, gathered by the generated manifest into ContentPlugin({ types }) |
| Model providers, and their order | plugins.ts |
| Identity providers and their names | IdentityPlugin(config, { sso: { providers } }) |
| SSO role mapping | IdentityPlugin(config, { sso: { resolveRole } }) |
| Copilot skills in code | CopilotPlugin({ skills }) |
| The storage backend | MediaServerPlugin({ provider }) |
| Per-type transfer identity fields, transfer ceilings | TransferPlugin({ identity, limits }) |
| The reader-tag resolver | SegmentsPlugin({ resolver }) in config/segments.ts |
| Webhook batch size, attempts, auto-disable, claim timeout | WebhooksPlugin({ … }) |
| Mail templates, batch size, claim lease | MailServerPlugin({ config }) |
| DB pool size and timeouts | DatabasePlugin({ poolMax, connectionTimeoutMillis, statementTimeoutMillis }) |
| Webhook endpoints; publication protection rules; workspace sharing and shared grants | The database, via the admin |