Each entry is the symptom as you will see it, what is actually wrong, and the fix. Where the code produces an exact message, the message is quoted so you can search for it.
The login rate limit trips for everyone
Symptom. One person’s failed sign-ins lock out every user, or the sessions list shows the same IP for every session.
Cause. TRUST_PROXY is unset behind a load balancer. Express ignores
X-Forwarded-For and every request reports the proxy’s address, so the login
limit — ten attempts a minute by default — collapses into one bucket for the
whole deployment.
Fix. Set the hop count: TRUST_PROXY=1 for a single proxy. Make sure the
proxy actually sends X-Forwarded-For; both halves are needed. See
configuration.
relation "users" does not exist on a fresh migrate
Symptom. apograph migrate fails on a new database with that Postgres error,
while an existing database migrates fine.
Cause. Plugin order. Migrations run in plugins.ts order with no
transaction spanning plugins, and workspaces’ memberships table references
identity’s users. WorkspacesPlugin before IdentityPlugin breaks only on a
clean install, which is why the mistake ships.
Fix. Put IdentityPlugin before WorkspacesPlugin, and add new plugins at
the end unless you know why not.
Migrating plugin "…" failed — applied N of M plugin(s) before it
Symptom. That message, followed by
Nothing is rolled back: the N plugin(s) above are committed.
Cause. One plugin’s migration failed. The ones before it are committed; the ones after it were not attempted. The message goes on to name plugin order as the usual cause when the error is a missing relation.
Fix. Fix the cause, then run apograph migrate again — it resumes from the
plugin that failed. Nothing needs undoing.
No package.json in … or any parent directory — run this inside an Apograph app.
Symptom. Any apograph command, from a shell whose working directory is
outside the app.
Cause. The CLI finds the app root by walking up from the working directory
to the nearest package.json. From /tmp or your home directory there is none.
Fix. cd into the app — any subdirectory works — and run it again.
dist/server/src/main.js does not exist — run `apograph build` first.
Symptom. apograph start refuses with that message. apograph migrate says the
same about dist/server/apograph.config.js and adds that it would have built for
you.
Cause. The app has not been built, or rootDir in a generated
tsconfig was changed so the compiled entry landed somewhere else. The layout
is a convention the CLI expects, not configuration it reads.
Fix. apograph build. If you did change rootDir, change it back.
The API works and the admin is a 404
Symptom. /api/... answers, / is not found, and the boot log warned
that no index.html was found under the static directory.
Cause. staticDir in a generated apograph.config.ts is
join(process.cwd(), 'dist/admin'). The process was started from a directory
other than the app root, or the admin was never built (apograph build --server
skips it).
Fix. Set WorkingDirectory= in the systemd unit or WORKDIR in the
Dockerfile to the app root, and run a full apograph build.
DATABASE_URL is not set — migrating needs to know which database to change
Symptom. That message from apograph migrate, though .env has the value.
Cause. The CLI loads .env from the app root it found, so a .env in a
subdirectory or a shell variable exported in another terminal is not seen. The
refusal is deliberate: an empty connection string would make pg fall through
to PGHOST and the OS user and migrate a database nobody named.
Fix. Put DATABASE_URL in <app root>/.env, or export it in the shell
that runs the command.
The admin UI half-works after an upgrade
Symptom. Nothing errors. The sidebar stops responding, a page renders
empty, a dialog does not open — after bumping some @apograph/* packages.
Cause. A partial upgrade. Two versions of a shared package in
node_modules means two React context instances, and a provider the consumer
cannot see.
Fix. npm ls @apograph/design-system react — two entries confirms it.
Install every @apograph/* package at one version in a single
npm install. See upgrading.
joinTableOf throws, or a many-relation writes to a missing table
Symptom. apps/server/src/content/index.ts throws at import naming a
relation, or at runtime a save fails on a table that does not exist.
Cause. drizzle-kit diffs top-level table exports only. A content type’s
table, or a many-relation’s join table, that is not re-exported from the
schema file is not in the migration. joinTableOf throws when the relation it
names is missing or renamed, so a dropped join table fails at build rather
than vanishing from the diff.
Fix. Re-export every type.table and one joinTableOf(type, 'field') per
many-relation from the file drizzle.config.ts points at, then
apograph generate again. See migrations.
No apps/server/drizzle.config.ts in …
Symptom. apograph generate refuses with that message.
Cause. The generation config for the app’s own content tables must be at exactly that path — the CLI looks nowhere else.
Fix. Create it with schema: './apps/server/src/content/index.ts' and
out: './migrations'. Both paths resolve from the app root, where the CLI
runs drizzle-kit — not from the config file’s own directory, whatever a
generated README says.
A media download answers 302, or a client gets empty bytes
Symptom. curl on /api/v1/media/assets/:id/raw prints nothing and exits
0; an HTTP library configured not to follow redirects sees a 302.
Cause. MEDIA_DIRECT_SERVE=signed-url is on. Authorisation runs first,
then the route redirects to a short-lived signed URL at the storage backend
rather than streaming the bytes itself.
Fix. Follow redirects — curl -L; most libraries do by default. Do not
cache the 302: it carries Cache-Control: private, no-store for a reason.
Every asset 404s after a redeploy
Symptom. The library lists every asset, entries still reference them, and every download is a 404.
Cause. MEDIA_LOCAL_ROOT defaults to ./.storage/media on the container’s
own disk, which a fresh container starts without. The database is intact, so
nothing warns.
Fix. Point it at a persistent volume, or configure an object-storage provider. The blobs from before are gone unless the old container’s filesystem still exists. See media storage.
401 or 403 from the public API
Symptom. A request with a token is refused.
Cause. They mean different things, and the message says which:
| Answer | Message | Meaning |
|---|---|---|
| 401 | Missing `Authorization: Bearer <token>` header. | No header, or the wrong scheme. |
| 401 | Invalid API token. | Unknown, revoked or expired — deliberately indistinguishable. |
| 403 | This token’s scope does not allow that operation. | A read token on a write route, or an upload. |
| 403 | status=draft requires a token with write scope. | ?status=draft or ?status=any with a read token. |
| 403 | This token does not cover that workspace. | X-Workspace-Id names a workspace outside the bucket, or one that does not exist. |
| 400 | This token covers N workspaces — name the one you want with the X-Workspace-Id header. | A multi-workspace token with no header. |
| 400 | Malformed X-Workspace-Id header. | The header is not a uuid. |
Fix. For a 401, re-check the secret — it was shown once at minting, and there is no way to recover it; mint a new one. For a 403, mint a token with the scope or bucket the call needs; there is no per-route override.
A localized read returns nothing, or a 400 on locale
Symptom. items is empty for a language you know has content, or
?locale=xx is a 400.
Cause. Locale scoping is strict: a translation group with no row in the
requested language is absent from the results, not substituted with the
default. And the slug must be one the I18nServerPlugin locale list declares
— an unknown slug is rejected rather than silently read as the default.
Fix. Check the entry is published in that language, and that the slug
matches locales in apps/server/config/i18n.ts. Removing a locale from that
list makes its rows unreachable and, by default, fails the boot with them
present — orphanedLocales: 'fail' is the setting that says so.
An image tag pointing at /api/v1/media/... shows a broken image
Symptom. Images from an expanded media field do not load in the browser.
Cause. The raw route requires the bearer token, and a browser does not send one. There are no public asset URLs; the URL a read hands out is for the same token that did the read.
Fix. Proxy the bytes through your own server, which holds the token, or copy assets to a CDN at build time. See the media API.
A field you wrote is not there when you read it back
Symptom. A POST or PATCH returns 200 and the field is missing or
unchanged.
Cause. A key in values that is not a declared field is dropped in
silence — not stored, not rejected. A misspelt name writes nothing.
Fix. Compare the key against GET /api/v1/content-types/:name, and read the
response body back when writing by hand.
A webhook endpoint is refused when you save it
Symptom. Use https://. Plain HTTP exposes the payload to anyone on the network path.
or … is a private or reserved address. Webhooks may only reach public hosts.
Cause. The URL policy. A webhook is the server making a request to an
address someone typed, so by default it must be https:// and must not
resolve to a loopback or RFC 1918 address — the check is repeated at every
send, after DNS resolution.
Fix. For local development, pass allowInsecureUrls: true and
allowPrivateNetworks: true to WebhooksPlugin(...). A scaffolded .env
lists WEBHOOKS_ALLOW_INSECURE_URLS and WEBHOOKS_ALLOW_PRIVATE_NETWORKS, but
a scaffolded plugins.ts registers WebhooksPlugin() with no configuration,
so those lines do nothing until you wire them through apograph.config.ts. Leave
both off in production.
What to read next
- Configuration — every environment variable, including the two most often wrong.
- Errors and status codes — what each status means on the public API, and where two causes answer identically on purpose.
- Upgrading — the lockstep rule behind the half-working admin.