Aphex
Deployment

Operations

Running Aphex in production — migrations on deploy, the background job worker, health checks, backups, caching and upgrades.

Everything that comes after the first successful deploy.

Migrations on deploy

Which mechanism applies depends on the database, and they are genuinely different:

DatabaseHow the schema gets there
SQLitePushed at startup by the adapter. No migration files, nothing to run.
PostgresCommitted migrations in drizzle/, applied by aphex migrate.

Never run db:push against a production Postgres database — it can drop columns silently. Always generate, review and commit a migration.

The Postgres workflow:

Locally, edit the Drizzle schema and generate:

pnpm db:generate

Read the SQL in drizzle/0NNN_*.sql before committing it. Commit the schema change and the migration together.

On deploy, you usually apply nothing by hand. The bundled container entrypoint runs the migration on the Postgres path before the app starts, and the app itself migrates on boot behind a pg_advisory_lock. Both are idempotent, and both are on by default — a single-instance deploy needs no migration step configured at all.

To run it explicitly (a pre-deploy hook, a one-off container), call the compiled CLI:

node node_modules/@aphexcms/cms-core/dist/cli/index.js migrate

It uses drizzle-orm rather than drizzle-kit, so it works inside the pruned production image where drizzle-kit — a devDependency — isn't present. Locally, pnpm exec aphex migrate is equivalent. The path is spelled out rather than using node_modules/.bin/aphex because that shim execs tsx, which pnpm prune --prod removes from the image.

With several replicas, run it as a pre-deploy step instead of on boot, so N containers don't race each other through the same DDL:

APHEX_SKIP_MIGRATE=true      # entrypoint doesn't migrate
APHEX_DB_AUTO_MIGRATE=false  # app doesn't migrate on boot either

On Render that's the service's Pre-Deploy Command; elsewhere, a CI step or a one-off container run.

On the first request after a deploy, the CMS hook calls initializeRLS() to ensure row-level security policies exist on cms_documents and cms_assets. It is idempotent and needs nothing from you.

Background jobs

Scheduled publishes and event consumers run on a durable, database-backed queue. In production nothing drives that queue unless you say so, and the failure mode is silent — jobs accumulate as pending rows and the scheduled post simply never goes live. Pick one of the two modes.

Embedded — one container

APHEX_EMBEDDED_WORKER=true

An in-process loop inside the app. No second service, no secret, no cron. This is the right answer for every single-container deploy, which includes all the one-click paths here.

Turn it off the moment you run more than one replica, or each replica runs its own loop against the same queue. (The queue leases jobs, so this is wasteful rather than corrupting — but it is still wrong.)

External — cron or a worker loop

APHEX_WORKER_SECRET=<openssl rand -base64 48>

That enables POST /api/internal/workers/run. Until the secret is set the endpoint returns 404, so it never exists as an unauthenticated surface. Then drive it:

curl -X POST https://cms.example.com/api/internal/workers/run \
  -H "Authorization: Bearer $APHEX_WORKER_SECRET"

Each call runs one bounded batch (jobs.batchSize, jobs.relayBatchSize), so throughput is set by your cadence — a minute is a reasonable default, less if you want scheduled publishes to land closer to their minute.

On Render that's a Cron Job service; on Railway a cron service; on a VPS, a crontab line or the bundled loop:

APHEX_WORKER_SECRET=  \
APHEX_WORKER_URL=https://cms.example.com/api/internal/workers/run \
APHEX_WORKER_INTERVAL_MS=5000 \
pnpm worker

The loop and the cron hit the same endpoint and the same code path as the embedded mode, so the three can't drift.

Checking it works

The response reports what the batch did:

{ "success": true, "result": { "claimed": 2, "completed": 2, "retried": 0, "failed": 0 } }

A 404 means no secret is configured server-side; a 401 means the presented secret doesn't match. The admin's Activity view shows queued, failed and dead-lettered jobs, with a Retry action for jobs that failed permanently.

See Events & Jobs for the queue's semantics — leases, backoff, dead-lettering, and why handlers must be idempotent.

Health checks

/healthz reports adapter health rather than mere liveness:

{ "ok": true, "db": true, "storage": true }

200 when both are healthy, 503 when either isn't — which is what tells an orchestrator to stop routing traffic without killing the container. It is unauthenticated by design, and deliberately reports nothing beyond those three booleans.

The route is yours to edit; the judgement behind it comes from checkHealth:

src/routes/healthz/+server.ts
import { json } from '@sveltejs/kit';
import { checkHealth } from '@aphexcms/cms-core/server';

export const GET = async ({ locals }) => {
	const health = await checkHealth(locals.aphexCMS);
	return json(health, { status: health.ok ? 200 : 503 });
};

checkHealth runs the adapter checks concurrently and bounds each one (5s by default, { timeoutMs } to change it). Both halves of that matter: an adapter that throws — a dead socket, expired bucket credentials — reports unhealthy rather than turning your probe into a 500, and an adapter that hangs reports unhealthy rather than hanging the probe until the platform's own timeout gives up. Add your own checks to the response freely; it returns a plain object, not a Response.

All the bundled deploy configs point their probe at it. On first boot, allow a generous start period: the schema push and the example-content seed take longer than a normal start, and an impatient probe will restart a container that was doing fine.

Backups

Three things, and only the first is obvious:

WhatHow
The databasePostgres: managed automated backups, or pg_dump to a bucket. SQLite: see below — do not just copy the file.
The mediaBucket: versioning + lifecycle rules. Volume: include it in the archive.
The secrets (AUTH_SECRET, encryption key, signing secret)A password manager. Losing them is unrecoverable in a way a database backup can't fix.

Documents are content plus a hash, so restoring the database restores everything including version history.

Copying a live SQLite file mid-write gives you a corrupt copy. Use the sqlite3 .backup command, or stop the container for the few seconds it takes to archive the volume. A tar of a running database is not a backup, it just looks like one.

And the part people skip: restore one, once, somewhere else, before you need to. An untested backup is a belief, not a plan.

Signed asset URLs

To hand a private asset to someone with no admin session — a client reviewing a draft, another app that doesn't share the CMS's cookies — mint a short-lived signed URL rather than exposing an API key:

APHEX_ASSET_SIGNING_SECRET=<32+ random chars>

/media/{id}/{filename}?sig=…&exp=… is HMAC-validated on every request and 403s after expiry. With the secret unset, signing is a no-op and verification always fails, so private assets stay session-only — the safe direction. Mint links with signAssetUrl from @aphexcms/cms-core/server.

Caching and CDN

Asset responses are served with Cache-Control: public, max-age=31536000, immutable, which is safe because asset URLs are content-addressed — the id is unique per upload, so a replaced image is a new URL.

Page responses are yours. The CMS doesn't touch SvelteKit page headers, so set them in your own routes. Pair a CDN in front of your origin with the published-data cache layer (Configuration → cache) and you have two tiers without much work.

Upgrading

git pull
docker compose -f docker-compose.prod.yml up -d --build app

Read CHANGELOG.md in the template repo first. The template is meant to be customized, so upstream changes are not applied to your project automatically — the changelog is the list of what changed and what you may want to port into your own copy. Anything touching drizzle/ means a migration to apply.

What to watch

  • Failed and dead-lettered jobs — the admin's Activity view. A consumer that started throwing will retry, back off and eventually dead-letter, quietly.
  • Volume usage — media grows without anyone deciding to grow it.
  • /healthz returning 503 — the storage adapter reports unhealthy when a bucket's credentials expire, which otherwise shows up as broken images long before anyone connects the two.
  • Slow queries — set ENABLE_QUERY_LOG=true and SLOW_QUERY_MS to log queries over a threshold. Nested content filters are unindexed JSON scans on both adapters; they're fine at starter scale and are the first thing to look at when they aren't.
Edit on GitHub

Last updated on