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 buildTwo 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.
| Flag | Builds |
|---|---|
| (none) | The server, then the admin |
--server | The server only |
--admin | The 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 migrateRun 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 startIt 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
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL | — | Required. Boot fails naming it if absent |
PORT | 3000 | What the process listens on |
TRUST_PROXY | unset | Set it behind a load balancer. It decides what req.ip resolves to, and therefore what the login rate limit counts |
API_DOCS | false | Publishes 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:
apograph buildapograph migrate- Start the new version
- 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.