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
| Variable | Default | What it does |
|---|---|---|
DATABASE_URL | — | PostgreSQL connection string. Required. |
PORT | 3000 | Port the API listens on. |
ADMIN_PORT | 4200 | Port the admin dev server listens on, and the origin ALLOWED_ORIGINS defaults to. |
API_PORT | PORT | Where the admin dev server proxies /api. |
API_DOCS | on outside production | Serve the Scalar reference on /reference. |
TRUST_PROXY | unset | How many reverse proxies sit in front. See below. |
MAX_REQUEST_BODY | 1mb | Largest 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 count — TRUST_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
| Variable | Default | What it does |
|---|---|---|
ALLOWED_ORIGINS | the admin dev origin | Comma-separated origins allowed to make state-changing calls. |
SESSION_TTL_SECONDS | 604800 | Session lifetime — seven days. |
INVITE_TTL_SECONDS | 604800 | Invitation lifetime. |
RESET_TTL_SECONDS | 3600 | Password-reset lifetime — one hour. |
LOGIN_RATE_LIMIT | 10 | Login attempts allowed per window. |
LOGIN_RATE_LIMIT_TTL_SECONDS | 60 | The 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
| Variable | What it does |
|---|---|
APOGRAPH_ROOT_ADMIN_EMAIL | Provisions an active admin on boot. Leave unset to skip. |
APOGRAPH_ROOT_ADMIN_PASSWORD | That account’s password. Bcrypt-hashed before storage. |
APOGRAPH_ROOT_ADMIN_NAME | That 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.
| 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 | Comma-separated domains an account may be created for. |
SSO_PROVISION_ROLE | viewer | The role a provisioned account lands on. |
SSO_ALLOW_PASSWORD_LOGIN | true | false makes the provider the only way in. |
SSO_SESSION_TTL_SECONDS | SESSION_TTL_SECONDS | A 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
| Variable | Default | What it does |
|---|---|---|
MEDIA_MAX_UPLOAD_BYTES | 52428800 | Max upload size — 50 MB. |
MEDIA_DIRECT_SERVE | off | signed-url redirects a download to the backend. |
MEDIA_DIRECT_SERVE_TTL_SECONDS | 300 | Lifetime of a signed URL. |
MEDIA_LOCAL_ROOT | ./.storage/media | Where local blobs are written. |
MEDIA_S3_BUCKET | — | S3 bucket. Required by the S3 provider. |
MEDIA_S3_REGION | auto | auto is what R2 expects; AWS needs its real region. |
MEDIA_S3_ENDPOINT | — | Omit for AWS; set it for R2, MinIO, Spaces, B2. |
MEDIA_S3_FORCE_PATH_STYLE | false | Path-style URLs, for MinIO and friends. |
MEDIA_S3_ACCESS_KEY_ID | — | Omit on a host with an instance role or IRSA. |
MEDIA_S3_SECRET_ACCESS_KEY | — | Omit with the above. |
MEDIA_AZURE_CONTAINER | — | Container name. Required by the Azure provider. |
MEDIA_AZURE_CONNECTION_STRING | — | Required by the Azure provider. |
MEDIA_GCS_BUCKET | — | Bucket name. Required by the GCS provider. |
MEDIA_GCS_PROJECT_ID | — | Optional; ADC is used otherwise. |
MEDIA_GCS_KEY_FILE | — | Path to a service-account key file. |
MEDIA_GCS_SIGN_WITH_IAM | false | Sign URLs through IAM rather than a private key. |
BLOB_READ_WRITE_TOKEN | — | Vercel 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
| Variable | Default | What it does |
|---|---|---|
COPILOT_ENABLED | false | The global kill switch. |
COPILOT_MAX_OUTPUT_TOKENS | 8192 | Ceiling on one model response. |
COPILOT_MAX_STEPS | 30 | Model calls in one run. |
COPILOT_WALL_CLOCK_MS | 300000 | Wall clock for a whole run. |
COPILOT_MAX_TOTAL_TOKENS | 400000 | Tokens across every call in a run. |
ANTHROPIC_API_KEY | — | Setting it registers the Claude backend. |
ANTHROPIC_BASE_URL | — | A gateway, proxy or regional endpoint. |
COPILOT_ANTHROPIC_MODELS | — | Comma-separated. The first is the default. |
COPILOT_OPENAI_BASE_URL | — | Setting it registers the OpenAI-wire backend. |
COPILOT_OPENAI_MODELS | — | Comma-separated. The first is the default. |
COPILOT_OPENAI_API_KEY | — | Empty 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
| Variable | Default | What it does |
|---|---|---|
MCP_ENABLED | false | Mounts the Model Context Protocol endpoint. |
MCP_CALL_TIMEOUT_MS | 30000 | Ceiling on one tools/call. |
MCP_MAX_RESULT_BYTES | 4194304 | Ceiling 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.
| Variable | What it bounds |
|---|---|
GRAPHQL_MAX_DEPTH | Nesting depth of one query. |
GRAPHQL_MAX_COMPLEXITY | The computed cost budget. |
GRAPHQL_MAX_FIELDS | Fields one query may select. |
GRAPHQL_MAX_QUERY_LENGTH | Bytes of query text accepted. |
GRAPHQL_SCHEMA_CACHE_TTL_MS | How 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.
| Variable | Default | What it does |
|---|---|---|
WEBHOOKS_DELIVERY_INTERVAL | 2000 | How often the sender looks for queued deliveries, in ms. 0 queues but never sends from this process. |
WEBHOOKS_TIMEOUT | 10000 | Per-request timeout, in ms. |
WEBHOOKS_RETENTION_DAYS | 30 | How long a finished delivery stays in the log. 0 keeps them forever. |
WEBHOOKS_ALLOW_INSECURE_URLS | false | Permit http:// destinations. Local development only. |
WEBHOOKS_ALLOW_PRIVATE_NETWORKS | false | Permit 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:
| Setting | Where |
|---|---|
| The locale list | I18nServerPlugin({ locales }) |
| Which plugins run, and in what order | plugins.ts |
| Content types | ContentPlugin({ types }) |
| Model providers, and their order | plugins.ts |
| Identity providers, and their names | IdentityPlugin(config, { sso }) |
| SSO role mapping | IdentityPlugin’s resolveRole handler |
| Copilot skills declared in code | CopilotPlugin({ skills }) |
| The storage backend | MediaServerPlugin({ provider }) |
| Per-type transfer identity fields | TransferPlugin({ identity }) |
| Transfer ceilings | TransferPlugin({ limits }) |
| Webhook endpoints, secrets and filters | The database — the admin’s Webhooks page |
| Webhook batch size, attempts, auto-disable, claim timeout | WebhooksPlugin({ … }) |
| The reader-tag resolver for audiences | SegmentsPlugin({ 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.