Features Apograph CMS on GitHub

Configuration

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Configuration is environment variables read by apps/server/apograph.config.ts, which assembles each plugin’s config object. Plugins do not read process.env themselves — that is what makes configuration a single, readable file rather than a scavenger hunt.

Copy .env.example to .env to start.

Core

VariableDefaultWhat it does
DATABASE_URLPostgreSQL connection string. Required.
PORT3000Port the API listens on.
ADMIN_PORT4200Port the admin dev server listens on, and the origin ALLOWED_ORIGINS defaults to.
API_PORTPORTWhere the admin dev server proxies /api.
API_DOCSon outside productionServe the Scalar reference on /reference.
TRUST_PROXYunsetHow many reverse proxies sit in front. See below.
MAX_REQUEST_BODY1mbLargest JSON body accepted.

TRUST_PROXY is required behind a load balancer

Unset, Express ignores X-Forwarded-For and every request reports the proxy’s address.

Two consequences, both bad. The login rate limit collapses into one bucket for the whole deployment, so one attacker’s ten requests a minute deny login to every user. And every session row records the proxy’s IP instead of the client’s, so the session list is useless for spotting an intrusion.

Prefer the hop countTRUST_PROXY=1 for a single proxy — which a client cannot forge past. It also accepts true (trust the whole forwarded chain; only safe on a network where nothing untrusted reaches the app directly) or a subnet or preset list passed to Express verbatim, such as loopback or 10.0.0.0/8.

Leave it unset only when the server is exposed directly.

A body over MAX_REQUEST_BODY is refused with a bare 413 by the body parser, before any controller or guard runs. Media uploads are multipart and unaffected — see MEDIA_MAX_UPLOAD_BYTES.

Identity

VariableDefaultWhat it does
ALLOWED_ORIGINSthe admin dev originComma-separated origins allowed to make state-changing calls.
SESSION_TTL_SECONDS604800Session lifetime — seven days.
INVITE_TTL_SECONDS604800Invitation lifetime.
RESET_TTL_SECONDS3600Password-reset lifetime — one hour.
LOGIN_RATE_LIMIT10Login attempts allowed per window.
LOGIN_RATE_LIMIT_TTL_SECONDS60The window.

There is no signing secret, and nothing to rotate

Sessions and one-time invite and reset tokens are opaque 256-bit random values checked against a row — not signed blobs. Revoking a session is deleting its row.

SESSION_SECRET and TOKEN_SECRET were listed here as required through 0.3. They were read by nothing, and 0.4.0 removed them. Delete them from your .env rather than carrying a value that protects nothing.

Root administrator

VariableWhat it does
APOGRAPH_ROOT_ADMIN_EMAILProvisions an active admin on boot. Leave unset to skip.
APOGRAPH_ROOT_ADMIN_PASSWORDThat account’s password. Bcrypt-hashed before storage.
APOGRAPH_ROOT_ADMIN_NAMEThat account’s display name.

Provisioning is idempotent and non-destructive: an existing account is left untouched. It is how a fresh self-hosted install gets its first way in.

Single sign-on

New in 0.4.0. The identity providers themselves are constructed in plugins.ts — they are adapters, not environment values. These settings shape the handshake and decide what a sign-in is allowed to do.

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_DOMAINSunsetComma-separated domains an account may be created for.
SSO_PROVISION_ROLEviewerThe role a provisioned account lands on.
SSO_ALLOW_PASSWORD_LOGINtruefalse makes the provider the only way in.
SSO_SESSION_TTL_SECONDSSESSION_TTL_SECONDSA shorter lifetime for SSO sessions alone.

Per-provider settings — SSO_OIDC_*, SSO_GITHUB_* and SSO_SAML_* — are on the single sign-on page, which also covers what each one means on its own wire.

Provisioning without a domain list is not possible, on purpose

An identity provider answers for everyone it knows, and a public one knows everyone. Left empty, SSO_PROVISION_DOMAINS means SSO creates nobody and only signs in accounts that already exist — Apograph is invite-only, and SSO replaces the credential check rather than the way in.

Media

VariableDefaultWhat it does
MEDIA_MAX_UPLOAD_BYTES52428800Max upload size — 50 MB.
MEDIA_DIRECT_SERVEoffsigned-url redirects a download to the backend.
MEDIA_DIRECT_SERVE_TTL_SECONDS300Lifetime of a signed URL.
MEDIA_LOCAL_ROOT./.storage/mediaWhere local blobs are written.
MEDIA_S3_BUCKETS3 bucket. Required by the S3 provider.
MEDIA_S3_REGIONautoauto is what R2 expects; AWS needs its real region.
MEDIA_S3_ENDPOINTOmit for AWS; set it for R2, MinIO, Spaces, B2.
MEDIA_S3_FORCE_PATH_STYLEfalsePath-style URLs, for MinIO and friends.
MEDIA_S3_ACCESS_KEY_IDOmit on a host with an instance role or IRSA.
MEDIA_S3_SECRET_ACCESS_KEYOmit with the above.
MEDIA_AZURE_CONTAINERContainer name. Required by the Azure provider.
MEDIA_AZURE_CONNECTION_STRINGRequired by the Azure provider.
MEDIA_GCS_BUCKETBucket name. Required by the GCS provider.
MEDIA_GCS_PROJECT_IDOptional; ADC is used otherwise.
MEDIA_GCS_KEY_FILEPath to a service-account key file.
MEDIA_GCS_SIGN_WITH_IAMfalseSign URLs through IAM rather than a private key.
BLOB_READ_WRITE_TOKENVercel Blob, when running off Vercel.

Which backend runs is not an environment variable

It is the provider plugins.ts constructs. These are that provider’s settings, and only the block matching your backend is read. Setting MEDIA_S3_BUCKET on a deployment wired to local disk does nothing at all.

MEDIA_PROVIDER never existed, though it was documented here through 0.3.

The default media root is ephemeral

./.storage/media is git-ignored and lives on the container’s own disk. A fresh container starts with an empty library and every asset URL 404s.

Point it at a persistent volume, or use S3. See media storage.

Copilot

VariableDefaultWhat it does
COPILOT_ENABLEDfalseThe global kill switch.
COPILOT_MAX_OUTPUT_TOKENS8192Ceiling on one model response.
COPILOT_MAX_STEPS30Model calls in one run.
COPILOT_WALL_CLOCK_MS300000Wall clock for a whole run.
COPILOT_MAX_TOTAL_TOKENS400000Tokens across every call in a run.
ANTHROPIC_API_KEYSetting it registers the Claude backend.
ANTHROPIC_BASE_URLA gateway, proxy or regional endpoint.
COPILOT_ANTHROPIC_MODELSComma-separated. The first is the default.
COPILOT_OPENAI_BASE_URLSetting it registers the OpenAI-wire backend.
COPILOT_OPENAI_MODELSComma-separated. The first is the default.
COPILOT_OPENAI_API_KEYEmpty for a local runtime wanting no auth.

Move the three run ceilings together — they are checked in the same loop, so raising one alone relocates the wall. See setting up the copilot.

MCP

VariableDefaultWhat it does
MCP_ENABLEDfalseMounts the Model Context Protocol endpoint.
MCP_CALL_TIMEOUT_MS30000Ceiling on one tools/call.
MCP_MAX_RESULT_BYTES4194304Ceiling on one result.

Once on, any holder of a full-scope token can drive content CRUD from an external agent — including publish and delete. See the MCP endpoint.

GraphQL

Read only when the GraphQL plugin is registered. They bound one request, because GraphQL lets a caller ask for arbitrarily much in a single query.

VariableWhat it bounds
GRAPHQL_MAX_DEPTHNesting depth of one query.
GRAPHQL_MAX_COMPLEXITYThe computed cost budget.
GRAPHQL_MAX_FIELDSFields one query may select.
GRAPHQL_MAX_QUERY_LENGTHBytes of query text accepted.
GRAPHQL_SCHEMA_CACHE_TTL_MSHow long a built schema is reused.

Webhooks

Read by the webhooks plugin. The endpoints themselves — URLs, secrets, filters — live in the database and are managed on the admin’s Webhooks page; these settings say how hard the sender pushes and where it is allowed to reach. Only the five a deployment genuinely varies are env-sourced; the rest are options on WebhooksPlugin({ … }) with defaults documented on the plugin page.

VariableDefaultWhat it does
WEBHOOKS_DELIVERY_INTERVAL2000How often the sender looks for queued deliveries, in ms. 0 queues but never sends from this process.
WEBHOOKS_TIMEOUT10000Per-request timeout, in ms.
WEBHOOKS_RETENTION_DAYS30How long a finished delivery stays in the log. 0 keeps them forever.
WEBHOOKS_ALLOW_INSECURE_URLSfalsePermit http:// destinations. Local development only.
WEBHOOKS_ALLOW_PRIVATE_NETWORKSfalsePermit loopback, link-local and RFC 1918 destinations.

The two allow flags widen what a typed URL can reach

A webhook is, precisely, “the server makes a request to an address a user typed” — the shape of every SSRF, and the reason the cloud metadata endpoint at 169.254.169.254 is refused by default. WEBHOOKS_ALLOW_PRIVATE_NETWORKS is for a self-hosted install whose receiver genuinely sits inside the same network; an internet-facing deployment must not have it.

WEBHOOKS_DELIVERY_INTERVAL=0 is how a deployment dedicates one node to outgoing traffic: the web nodes enqueue, one process sends, and the rows are claimed with FOR UPDATE SKIP LOCKED so several senders simply send faster.

Releasing

NPM_TOKEN and GITHUB_TOKEN are read only by the release script. Nothing else in the application looks at them.

Configuration that is not environment variables

Some things are code, in apps/server/apograph.config.ts and plugins.ts, because they are lists and objects rather than strings:

SettingWhere
The locale listI18nServerPlugin({ locales })
Which plugins run, and in what orderplugins.ts
Content typesContentPlugin({ types })
Model providers, and their orderplugins.ts
Identity providers, and their namesIdentityPlugin(config, { sso })
SSO role mappingIdentityPlugin’s resolveRole handler
Copilot skills declared in codeCopilotPlugin({ skills })
The storage backendMediaServerPlugin({ provider })
Per-type transfer identity fieldsTransferPlugin({ identity })
Transfer ceilingsTransferPlugin({ limits })
Webhook endpoints, secrets and filtersThe database — the admin’s Webhooks page
Webhook batch size, attempts, auto-disable, claim timeoutWebhooksPlugin({ … })
The reader-tag resolver for audiencesSegmentsPlugin({ resolver })

A missing value the server cannot run without fails at load, not several seconds into boot — which matters most for DATABASE_URL, where an empty string would otherwise reach pg as “use the environment defaults” and connect to something nobody named.