Features Apograph CMS on GitHub

Troubleshooting

Symptom, cause, fix — for the failures that look like bugs and are configuration.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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:

AnswerMessageMeaning
401Missing `Authorization: Bearer <token>` header.No header, or the wrong scheme.
401Invalid API token.Unknown, revoked or expired — deliberately indistinguishable.
403This token’s scope does not allow that operation.A read token on a write route, or an upload.
403status=draft requires a token with write scope.?status=draft or ?status=any with a read token.
403This token does not cover that workspace.X-Workspace-Id names a workspace outside the bucket, or one that does not exist.
400This token covers N workspaces — name the one you want with the X-Workspace-Id header.A multi-workspace token with no header.
400Malformed 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.

  • 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.