Features Apograph CMS on GitHub

Deployment

Getting Apograph onto your own infrastructure and keeping it there.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

A deployment is two things: one Node process, and PostgreSQL 16. There is no queue, no worker, no cache server and no search cluster, and adding one is not a prerequisite for anything on this site.

The Node process serves both halves. The API is mounted under /api, and the built admin bundle is served as static files from the same process, so the UI and the API share one origin.

Build

apograph build

Two steps behind one command. tsc compiles the server and everything it imports into dist/server; Vite builds the admin into dist/admin. An app with no apps/admin/index.html skips the second step and is API-only.

FlagBuilds
(none)The server, then the admin
--serverThe server only
--adminThe admin bundle only

Splitting them is useful when the admin is built once and the server is rebuilt per commit, or when the two are built by different jobs.

Migrate, as a release step

apograph migrate

Run it before the new server version starts, as its own step — not at boot.

Migrations are applied in plugin-list order with no transaction spanning plugins, and they are not safe to run concurrently from several instances. A deployment that migrates on boot runs the whole set once per replica, in parallel, on the first rollout that scales past one.

apograph migrate compiles the server first and then reads the compiled config and plugin list, rather than reading your TypeScript directly. That is why it takes longer than it looks like it should, and why it cannot run against a tree that does not compile.

Not `nx run server:db:migrate`

That is the command inside the Apograph monorepo, where @apograph/nx infers the target. An app installed from npm has no Nx, and apograph migrate is the adapter over the same implementation. See the CLI reference.

Run

apograph start

It runs dist/server/src/main.js, and refuses with a message naming the missing file if you have not built.

The working directory is load-bearing

staticDir in a generated apograph.config.ts is join(process.cwd(), 'dist/admin'). Start the process from anywhere other than the app root and the admin bundle is not found — the API serves normally and the UI is a 404, which reads as a broken deployment rather than as a wrong cwd.

Set WorkingDirectory= in a systemd unit, or WORKDIR in a Dockerfile, to the app root.

The server logs a warning and serves the API alone when there is no index.html under staticDir. That is the signal to look for: the UI being missing is reported at boot, not at the first request.

What it needs from the environment

VariableDefaultNotes
DATABASE_URLRequired. Boot fails naming it if absent
PORT3000What the process listens on
TRUST_PROXYunsetSet it behind a load balancer. It decides what req.ip resolves to, and therefore what the login rate limit counts
API_DOCSfalsePublishes the API reference and GraphiQL from this instance
SESSION_*, MEDIA_*, …See configuration

apograph.config.ts is the one file that reads process.env. Everything else receives typed values, which is what makes a misconfiguration a boot failure rather than a runtime surprise.

The reverse proxy

Serve the admin and the API from one origin. The session cookie is httpOnly and SameSite=lax, and the media download hardening assumes the two share a host. Splitting them across origins is not a supported layout, and the symptom is a sign-in that appears to succeed and then does not stick.

A minimal proxy passes everything to the one upstream:

location / {
    proxy_pass         http://127.0.0.1:3000;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
}

X-Forwarded-For is what TRUST_PROXY teaches the app to believe. Without the header the app sees the proxy’s address for every request; without the setting it ignores the header. Both halves are needed, and getting one of them wrong makes the login rate limit count the whole internet as one client.

Uploads pass through the proxy, so its body limit has to be at least the app’s — bodyLimit in the config, and client_max_body_size in nginx.

Health checks

There is no health endpoint. Point the check at the root path, which the admin’s index.html answers, or at an API route that does not require a session. A check that hits the database on every probe is not something the product offers, and adding one is a plugin.

Storage

The default media storage root is a directory on local disk, which a container loses on restart. Give it a volume, or configure one of the object-storage providers. Media storage covers the choice and why the default is what it is.

Upgrading

@apograph/* dependencies are pinned exactly, with no caret, and a generated app’s README says to upgrade them as a set.

That is not tidiness. A partial upgrade can leave two copies of a shared package in node_modules — two React context instances, and an admin whose sidebar silently stops talking to its provider. Nothing errors.

The order of a release is the same every time:

  1. apograph build
  2. apograph migrate
  3. Start the new version
  4. Stop the old one

Migrations run forward only; there is no down. A rollback is a deploy of the previous application version against a database that has already migrated, which works for an additive migration and does not for a destructive one. Read the migration before you plan the rollback.

Operating notes

outbox_events keeps its dispatched rows — nothing prunes them, and that is deliberate, since they are the record every audit row is derived from. It is your table growing, and the database page has the query for finding events that parked after exhausting their retries.

What already covers the rest

  • Configuration — every environment variable, and the two that are most often wrong.
  • Security — the posture you are taking on, including what is not defended.
  • Database and migrations — the ordering constraint between plugins, and what each table is for.
  • The CLI reference — every command and flag.