Which file you edit, which process reads it, and what happens when a value is wrong. For the numbered install path, see Install.
This page does not list every variable with its type and default. apps/hocuspocus.server/ENV.md owns that table, and it mirrors the validation schema, so it cannot drift.
| File | Used by | Tracked in git |
|---|---|---|
.env.example |
The template you copy. Never read at runtime | Yes |
.env.production |
A production deployment, through --env-file |
No |
.env.local |
Local development. make dev-local generates it |
No |
.env.development |
Local development defaults. Generated on first run | No |
Next step: for a server, copy .env.example to .env.production and edit that one. Leave .env.example alone, so the next person still has a clean template.
Runtime values are read when a container starts. Change one, recreate the container, done.
Build values are baked into a compiled bundle. Every name starting with NEXT_PUBLIC_ is one of these. Changing it in .env.production does nothing at all until you run make build again.
That difference is the most common configuration mistake here. A NEXT_PUBLIC_ value that looks correct in your environment file can be months out of date in the running bundle.
Compose gives your shell environment precedence over --env-file, and a running container keeps the value it started with.
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --force-recreateNext step: run that after any runtime change, then confirm with make status-prod. For a NEXT_PUBLIC_ change, run make build first.
.env.example is organised into sections. These are the ones that matter for a deployment.
| Group | Holds | Notes |
|---|---|---|
| Application and security | Environment name, ports, secrets | |
| Database | DATABASE_URL |
Your own PostgreSQL. Nothing in the compose file provides it |
| Redis | Host, port, database index, timeouts | Shared by sync, queues, and the rate limiter |
| Supabase, server-side | Project URL, service-role key | Never expose the service-role key to a browser |
| Supabase, client-side | The NEXT_PUBLIC_ pair |
Build values. See above |
| Storage | Endpoint, region, bucket, key pair | Set PERSIST_TO_LOCAL_STORAGE=false |
| SMTP or Resend credentials | The worker sends; the API never does | |
| Push notifications | VAPID key pair | Generate with bunx web-push generate-vapid-keys |
| CORS | Allowed origins | |
| Rate limiting | RATE_LIMIT_MAX and the window |
Default is 100 requests per 15 minutes per address |
| Logging | Level and format | |
| Observability | Metrics and error reporting | Optional |
The template is written for local development, so three of its defaults are wrong for a server.
PERSIST_TO_LOCAL_STORAGE=true must become false. The REST API runs two replicas with no shared volume, so an upload lands in one container, is invisible to the other, and disappears on redeploy.
NEXT_PUBLIC_RESTAPI_URL must end in /api. There is no /api/v1 route. An older template carried one, and the neighbouring SERVER_RESTAPI_URL was already correct, which is how the mistake survived unnoticed. Check your own .env if you copied an early template.
ACME_EMAIL is missing entirely. Traefik falls back to the maintainer's address for Let's Encrypt registration. Add it.
These appear in the template and are read nowhere. Do not spend time on them.
STORAGE_TYPE— the code branches onPERSIST_TO_LOCAL_STORAGEinstead.JWT_SECRET— no consumer, and the production compose file never passes it.- The four
*_REPLICASvariables — no compose file reads them. Replica counts are set indocker-compose.prod.ymldirectly.
Next step: leave them as they are. Removing them is a repository change, not a deployment step.
Replica counts live in docker-compose.prod.yml. Raising them is not uniform, because the three backend processes buy different things from a replica. Postgres connections are the shared ceiling, so check your database max_connections first.
The short version: the REST API scales fully. The collaboration socket gains connection capacity, but not document capacity. The worker costs twice the database connections of the other two.
Next step: read apps/hocuspocus.server/Readme.md before raising any count.
Most misconfiguration here fails quietly rather than loudly. Install lists the silent ones together. Two are worth repeating.
Redis unavailable turns rate limiting off, and every request passes. That is deliberate — the alternative was refusing every request while Redis recovered — but it means a Redis problem widens your exposure instead of narrowing it.
An upload cap under 1 MB is ignored and floored to 10 MB, with a warning at startup. Next step: read the startup log after a cap change.
The MCP connector at /api/mcp lets people use docs.plus from Claude or ChatGPT. It needs the Supabase OAuth server. For the user side, see Use docs.plus from Claude or ChatGPT. For the tools and limits, see MCP connector reference.
The chat tools also need SUPABASE_SERVICE_ROLE_KEY. Without it, they answer Chat is not available on this docs.plus server. Settings > Connected apps needs the key too, to read each app's redirect URIs. Without it, every connected app shows as unverified. The limit of 60 tool calls per minute needs Redis. Without Redis, that limit is off.
Hosted Supabase. In the Supabase dashboard, open Authentication > OAuth Server.
- Turn on Enable the Supabase OAuth Server.
- Check Site URL. It comes from Authentication > URL Configuration, and it must be your webapp address.
- Set Authorization Path to
/oauth/consent. - Turn on Allow Dynamic OAuth Apps.
- Choose Save changes.
- Open Project Settings > JWT Keys. The current signing key must be asymmetric (
ES256orRS256). If the legacy JWT secret is still current, choose Rotate keys. AI apps ask foropenid, and Supabase cannot sign that ID token with the legacyHS256secret. The token step then fails withHS256 is not supported for ID token signing. Rotation signs nobody out. Do not revoke the legacy secret: theanonandservice_rolekeys are signed with it.
The Supabase CLI does not push these settings, so set them in the dashboard.
Local stack. packages/supabase/config.toml already turns the OAuth server on.
[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
allow_dynamic_registration = trueAfter you change that file, restart Supabase. Run this at the repository root.
bun --filter @docs.plus/supabase_back stop
bun --filter @docs.plus/supabase_back startCheck discovery. Run this from any directory.
curl -s <SUPABASE_URL>/auth/v1/.well-known/openid-configurationReplace <SUPABASE_URL> with your Supabase project URL, from the Supabase project settings. Locally it is http://127.0.0.1:54321.
The answer must list registration_endpoint. If it is missing, the OAuth server or dynamic registration is off. Copy the issuer value exactly, and set MCP_AUTH_ISSUER to it. On a server, recreate the containers, as above. Locally, stop and restart make dev-local.
Check the endpoint. Run this from any directory.
curl -si -X POST <PUBLIC_RESTAPI_URL>/api/mcpReplace <PUBLIC_RESTAPI_URL> with the public origin of your REST API, such as https://prodback.docs.plus.
The answer is 401, with a WWW-Authenticate header that names a resource_metadata URL. Open that URL. Its authorization_servers entry must equal the issuer you copied, character for character. No proxy change is needed, because the connector sits under /api.
Refuse connected-app tokens in Supabase. Supabase applies the same RLS to an OAuth token as to a session token, as its Token Security and RLS guide says. packages/supabase/scripts/31-connected-app-token-gate.sql makes Supabase refuse a token that carries client_id. It covers the Data API, Storage and Realtime. The MCP tools use the service-role key, so the script does not change them.
A new install runs this script with the other numbered files, as Install says. An existing install runs it once in the SQL editor. The paired migration 20260928120000_refuse_connected_app_tokens has the same body, for a supabase db push. Both are safe to run again.
Before you run it, check two things.
- Run
select rolconfig from pg_roles where rolname = 'authenticator';. If the answer already setspgrst.db_pre_request, merge both checks into one function first. The script replaces that setting. - Check that PostgREST is version 12 or later. The Supabase dashboard shows it under Project Settings > Infrastructure. An older version still refuses the token, but answers
500instead of403.
After it runs, open a document with chat while signed in. Messages load and a send works. A connected app's token now gets 403 with the code connected_app from <SUPABASE_URL>/rest/v1/. One MCP tool call still works.
The script adds a policy to each table in the supabase_realtime publication. After you add a table to that publication, run the script again.
If the site breaks, this turns the Data API check off. Run it in the SQL editor.
alter role authenticator reset pgrst.db_pre_request;
notify pgrst, 'reload config';The Storage and Realtime policies stay. Each is named Connected apps use only the MCP server. They sit on storage.objects, on realtime.messages, and on each public table in supabase_realtime. Drop each one with drop policy if exists.
docs.plus does not use passwords. People sign in with Google or an email link. Turn on this hook, so that no account can sign in with a password.
packages/supabase/scripts/32-password-sign-in-hook.sql creates the function public.hook_block_password_tokens. A new install runs it with the other numbered files. An existing install runs it once in the SQL editor, or pushes the paired migration 20260928130000_reject_password_sign_in_hook. Both are safe to run again.
The function is a Custom Access Token hook, which every Supabase plan has. It refuses a token for a password sign-in and passes every other sign-in through unchanged. Keep Confirm email on. With it off, a first sign-in by email link also fails.
Hosted Supabase. In the Supabase dashboard, open Authentication > Hooks. Add the Customize Access Token (JWT) Claims hook, choose Postgres, and pick that function. The hook runs each time Supabase issues a token. If sign-in or a token refresh fails after you turn it on, turn the hook off again.
Local stack. packages/supabase/config.toml has the hook block, commented out.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://postgres/public/hook_block_password_tokens"It stays off locally, because the backend e2e scripts and document-swarm sign in with a password. With the hook on, those scripts fail. To turn it on, remove the # marks. Also set enable_confirmations = true under [auth.email], so that a first email-link sign-in still works. Then restart Supabase. Run this at the repository root.
bun --filter @docs.plus/supabase_back stop
bun --filter @docs.plus/supabase_back startapps/hocuspocus.server/ENV.md— every backend variable, its type, and its default.- Install — the numbered path, and the verification step.