Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Configuration

The complete environment reference, and the two settings most often got wrong.

Documents 0.13.1 Updated Edit this page Report a problem

On this page
  • Configuration is environment variables, read only by apps/server/config/*.ts (one module per plugin) and assembled in apps/server/orthacms.config.ts.
  • Every read goes through the readers in @orthacms/utils-server (readEnv, readFlag, readPositiveInt, …). Plugins never touch process.env.
  • The orthacms CLI 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, -1 and 0 are refused — except OUTBOX_RETENTION_DAYS and TRUST_PROXY, where 0 means something.
  • Flags are true only for the literal true. 1, yes, on read 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 volume

Core

VariableDefaultWhat it does
DATABASE_URL—PostgreSQL connection string. Required.
NODE_ENVunsetproduction marks the session cookie Secure and turns the API reference off. Only development, test, production are accepted.
PORT3000Port the API listens on.
ADMIN_PORT4200Port the admin dev server listens on; also the default ALLOWED_ORIGINS.
API_PORTPORTMonorepo only: where the admin dev server proxies /api. A generated app’s Vite config uses PORT.
API_DOCSon outside productionServe the Scalar reference on /reference, and GraphiQL when GraphQL is registered.
TRUST_PROXYunsetHow many reverse proxies sit in front. See below.
MAX_REQUEST_BODY1mbLargest JSON body. Over it: a bare 413 from the parser. Uploads are multipart and unaffected.
OUTBOX_RETENTION_DAYS30Days 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

VariableDefaultWhat it does
ALLOWED_ORIGINShttp://localhost:<ADMIN_PORT>Comma-separated origins allowed to make state-changing calls. Another browser origin gets 403 Origin not allowed.
SESSION_TTL_SECONDS604800Session lifetime (7 days).
INVITE_TTL_SECONDS604800Invitation lifetime.
RESET_TTL_SECONDS3600Password-reset lifetime (1 hour).
LOGIN_RATE_LIMIT10Login attempts per window.
LOGIN_RATE_LIMIT_TTL_SECONDS60The 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

VariableWhat it does
ORTHACMS_ROOT_ADMIN_EMAILProvisions an active admin on boot. Unset skips it.
ORTHACMS_ROOT_ADMIN_PASSWORDIts password, bcrypt-hashed. Required once the email is set — the boot refuses otherwise.
ORTHACMS_ROOT_ADMIN_NAMEDisplay name. Optional.
  • Idempotent and non-destructive: an existing account keeps its password, role and status.
  • It is the one account SSO_ALLOW_PASSWORD_LOGIN=false never locks out.

Single sign-on

VariableDefaultWhat it does
SSO_PUBLIC_BASE_URLfirst allowed originThe origin browsers reach the API on. Builds the redirect_uri.
SSO_REQUEST_TTL_SECONDS600How long one sign-in attempt stays live.
SSO_PROVISION_DOMAINSunset (off)Comma-separated domains an account may be created for. Reference server only.
SSO_PROVISION_ROLEviewerRole for a provisioned account. Reference server only.
SSO_ALLOW_PASSWORD_LOGINtruefalse makes SSO the only way in (except the root admin). Anything but false keeps passwords. Reference server only.
SSO_SESSION_TTL_SECONDSSESSION_TTL_SECONDSShorter 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.

VariableDefaultWhat it does
MEDIA_MAX_UPLOAD_BYTES52428800Max upload (50 MB).
MEDIA_DIRECT_SERVEoffsigned-url redirects downloads to the backend. Any other value is off.
MEDIA_DIRECT_SERVE_TTL_SECONDS300Signed-URL lifetime.
MEDIA_LOCAL_ROOT./.storage/mediaLocal disk root. Ephemeral in a container — use a volume.
MEDIA_S3_BUCKET—Required by S3.
MEDIA_S3_REGIONautoauto suits R2; AWS needs its real region.
MEDIA_S3_ENDPOINT—Omit for AWS; set for R2, MinIO, Spaces, B2…
MEDIA_S3_FORCE_PATH_STYLEfalsePath-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_IAMfalseSign 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

VariableDefaultWhat it does
COPILOT_ENABLEDfalseGlobal kill switch. false unregisters every /api/copilot route.
COPILOT_MAX_OUTPUT_TOKENS8192Ceiling on one model response.
COPILOT_MAX_STEPS30Model calls per run. Reference server only.
COPILOT_WALL_CLOCK_MS300000Wall clock per run. Reference server only.
COPILOT_MAX_TOTAL_TOKENS400000Tokens 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_MODELSa built-in listComma-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_MODELSllama3.1Comma-separated. The first is the default.
COPILOT_OPENAI_API_KEY—Empty for a local runtime with no auth.
  • COPILOT_ENABLED=true with 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 0 fails at boot. They are counted in Postgres, so they hold across instances and survive a restart; a refused run is a 429. See usage limits.
COPILOT_ENABLED=true
ANTHROPIC_API_KEY=sk-ant-…
# or a local model:
# COPILOT_OPENAI_BASE_URL=http://localhost:11434/v1

MCP

VariableDefaultWhat it does
MCP_ENABLEDfalseMounts POST /api/v1/mcp.
MCP_CALL_TIMEOUT_MS30000Ceiling on one tools/call.
MCP_MAX_RESULT_BYTES4194304Ceiling 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.

VariableDefaultWhat it bounds
GRAPHQL_MAX_DEPTH8Nesting depth of one query.
GRAPHQL_MAX_COMPLEXITY1000The computed cost budget.
GRAPHQL_MAX_FIELDS500Fields one query may select.
GRAPHQL_MAX_QUERY_LENGTH16384Bytes of query text.
GRAPHQL_SCHEMA_CACHE_TTL_MS60000How 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.

VariableDefaultWhat it does
SCHEMA_BUILDERfalseTurns editing on. Still refused with NODE_ENV=production and without the source tree.
SCHEMA_BUILDER_ROOTapps/serverThe server app’s folder, resolved against the directory the server starts in.
SCHEMA_BUILDER_RESTARTwatchmanual 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

VariableDefaultWhat it does
SEGMENTS_READER_TAGS_HEADERx-reader-tagsThe 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.

VariableDefaultWhat it does
WEBHOOKS_DELIVERY_INTERVAL2000How often the sender polls the queue, in ms.
WEBHOOKS_TIMEOUT10000Per-request timeout, in ms.
WEBHOOKS_RETENTION_DAYS30Days a finished delivery stays in the log.
WEBHOOKS_ALLOW_INSECURE_URLSfalsePermit http://. Local development only.
WEBHOOKS_ALLOW_PRIVATE_NETWORKSfalsePermit 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.

Mail

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.

VariableDefaultWhat it does
MAIL_PROVIDERunsetsmtp. 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_TOunsetReply-to, when not MAIL_FROM.
MAIL_PRODUCT_NAMEOrtha CMSWhat the copy calls the product.
MAIL_REVEAL_LINKSfalseAlso return the raw link. Debugging only.
MAIL_DELIVERY_INTERVAL_MS2000Queue poll interval, in ms.
MAIL_MAX_ATTEMPTS5Attempts before a message is a dead letter.
SMTP_HOST—Required with smtp.
SMTP_PORT587465 for implicit TLS.
SMTP_SECUREport === 465TLS from the first byte. Leave empty on 587.
SMTP_USER / SMTP_PASSWORDunsetRelay credentials.
SMTP_REQUIRE_TLSfalseRefuse a relay that cannot upgrade to TLS.
SMTP_CONNECTION_TIMEOUT_MStransport defaultConnection 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:

SettingWhere
Locales, and what to do with rows in a removed localeconfig/i18n.ts — locales, orphanedLocales
Which plugins run, and their orderplugins.ts
Content typesOne file each under src/content/, gathered by the generated manifest into ContentPlugin({ types })
Model providers, and their orderplugins.ts
Identity providers and their namesIdentityPlugin(config, { sso: { providers } })
SSO role mappingIdentityPlugin(config, { sso: { resolveRole } })
Copilot skills in codeCopilotPlugin({ skills })
The storage backendMediaServerPlugin({ provider })
Per-type transfer identity fields, transfer ceilingsTransferPlugin({ identity, limits })
The reader-tag resolverSegmentsPlugin({ resolver }) in config/segments.ts
Webhook batch size, attempts, auto-disable, claim timeoutWebhooksPlugin({ … })
Mail templates, batch size, claim leaseMailServerPlugin({ config })
DB pool size and timeoutsDatabasePlugin({ poolMax, connectionTimeoutMillis, statementTimeoutMillis })
Webhook endpoints; publication protection rules; workspace sharing and shared grantsThe database, via the admin