Aphex
Deployment

Deployment

Ship Aphex to production — one-click on Render or Railway, or self-hosted with Docker. Platform comparison, production checklist, full environment variable reference.

Aphex is a SvelteKit app using adapter-node, so it deploys like any Node web service: one process, one port, node build. What makes it easier to host than most CMSs is the default database — SQLite in a file — which means a complete production deploy can be one container with one mounted volume. No database to provision, nothing to wire together.

If you want it running in the next few minutes:

Which path

The real question is not which host, it's where your database and your uploaded media live. Everything else follows from that.

PathDatabaseMediaCost/monthGood when
RenderSQLite on a diskThe same disk~$8You want a blueprint and a URL, and one instance is plenty.
RailwaySQLite on a volumeThe same volume~$5, usageSame, cheaper, if you don't mind adding the volume by hand.
Coolify / DokploySQLite or PostgresVolume or S3Your VPSYou already have a server and want everything on it.
Docker anywhereYour choiceYour choiceYour infraFly, a bare VPS, Kubernetes, a host not listed here.
Other platformsPostgres or TursoS3, requiredVariesVercel, Netlify, Cloudflare, buildpack hosts — read first.

Choosing your database

Both adapters run the same cross-dialect conformance suite, so neither is missing features — but that is a statement about capability, and production is an operational question. The two come apart there:

SQLite on a volumePostgres + S3
DeploysStop-then-start — brief downtime every release, because a volume attaches to one instanceRolling, no downtime
ReplicasOne, by constructionAs many as you like
BackupsA file you copy yourself, or Litestream you set upPoint-in-time recovery from any managed provider
WritesSerialized — editors contend with the job queue's own writesConcurrent
ReachInside the containerAny connection string: psql, BI, dashboards
Cost~$5–8/month, one containerAdd a database and a bucket

Pick SQLite to try Aphex, for an internal tool, or for a site where a few seconds of downtime on deploy costs nothing. It is genuinely less to run and less to back up.

Pick Postgres for a site you would be paged about. Not because you will outgrow SQLite's capacity — most sites never would — but because zero-downtime deploys and real point-in-time recovery are the things you miss at the worst possible moment.

Switching later is one environment variable and no code change, so this is a reversible decision — but move media to a bucket before the first upload, because switching storage does not relocate files already written to disk.

The two configurations

Mount a volume at /data and put both the database and the uploads on it:

APHEX_SQLITE_URL=file:/data/aphex.db
APHEX_UPLOADS_DIR=/data/uploads
APHEX_EMBEDDED_WORKER=true

That is the whole configuration. APHEX_DATABASE is unset — SQLite is the default.

APHEX_DATABASE=postgres
DATABASE_URL=postgres://user:pass@host:5432/dbname

S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
S3_BUCKET=my-bucket
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_URL=https://media.example.com

All four S3_* credentials must be set together — with any one missing the app silently stays on local disk. Running more than one replica? Also set APHEX_DB_AUTO_MIGRATE=false and migrate as a deploy step, and leave APHEX_EMBEDDED_WORKER off — see Operations.

Both one-click paths ship a Postgres variant: Render has a second blueprint, Railway is two clicks and two variables. Details on either adapter: Database.

What a deploy actually needs

Four things. Every guide here is a variation on getting them right.

A stable AUTH_SECRET. It signs session cookies and API keys.

openssl rand -base64 48

Generate it once and keep it. Rotating it signs every user out and invalidates every API key — so it belongs in your password manager, not only in the platform's env editor.

A correct AUTH_URL. The app's public origin, protocol and host exactly.

This is the one that bites. adapter-node has no idea what hostname it is served on — it builds event.url from its own internal host:port unless ORIGIN says otherwise. Better Auth compares that against AUTH_URL and, on a mismatch, declines the request. The decline lands on the API catch-all as a bare 404, so sign-up fails with nothing in the log that names the cause.

The bundled docker-entrypoint.sh derives it from the platform (RENDER_EXTERNAL_URL, RAILWAY_PUBLIC_DOMAIN, COOLIFY_URL, FLY_APP_NAME) when you haven't set one, which is what makes a one-click deploy possible at all — the hostname doesn't exist until provisioning finishes. Set it explicitly the moment you attach a custom domain, because every link in an outgoing email is built from it.

Somewhere durable for state. The database and the uploads.

Both default to paths inside the container, which is fine on your laptop and wrong everywhere else — a redeploy replaces the container and takes them with it. Point both at a mounted volume:

APHEX_SQLITE_URL=file:/data/aphex.db
APHEX_UPLOADS_DIR=/data/uploads

Or move media to a bucket with the S3_* variables. Do that before the first upload: switching later doesn't move files already written to disk.

Something to run the job queue. Scheduled publishes and event consumers.

In production the in-process loop is off by default, so unless you turn it on the queue fills and never drains — a scheduled publish is accepted, and then simply never happens. There is no error; it's the kind of thing noticed weeks later by a post that never went live.

APHEX_EMBEDDED_WORKER=true    # single container — the usual answer

Running more than one replica? Leave it off and drive the worker endpoint from outside instead. See Operations.

Domains, origins and proxies

Three variables describe where the app lives. Most deploys only ever set the first.

VariableWho reads itDefault
AUTH_URLBetter AuthDerived from the platform by the entrypoint
ORIGINadapter-nodeDerived from AUTH_URL by the entrypoint
AUTH_TRUSTED_ORIGINSBetter AuthAUTH_URL

ORIGIN is the one doing the real work. adapter-node does not know what hostname it is served on — it reconstructs event.url from ORIGIN, and falls back to its own localhost:3000 without one. Better Auth then compares that against AUTH_URL and declines on a mismatch, which surfaces as a bare 404 from the auth routes with nothing in the log naming the cause. The bundled entrypoint derives ORIGIN from AUTH_URL, so setting AUTH_URL correctly is normally the whole job.

Attaching a custom domain

Point DNS at the service, then set AUTH_URL to the new origin and redeploy:

AUTH_URL=https://cms.example.com

Protocol and host exactly, no trailing slash, no path. Do this even though the one-click deploy worked without it: the entrypoint's platform fallback returns the *.onrender.com / *.up.railway.app hostname, so until you change it, every password-reset and invitation email keeps linking people to the old address.

AUTH_TRUSTED_ORIGINS replaces the default, it does not extend it. It reads AUTH_TRUSTED_ORIGINS || AUTH_URL, so the moment you set it to add a second origin you must list your own as well — otherwise the app stops trusting the domain it is served from and sign-in breaks:

# wrong — drops the app's own origin
AUTH_TRUSTED_ORIGINS=https://www.example.com

# right
AUTH_TRUSTED_ORIGINS=https://cms.example.com,https://www.example.com

Only set it when another origin calls this app's auth routes — a separate frontend, or an apex and www that both serve the admin.

Behind a reverse proxy or tunnel

Caddy, nginx, Traefik, a Cloudflare Tunnel — the proxy terminates TLS and forwards plain HTTP to the container, so the app sees http on an internal port and would build the wrong origin for itself.

Set AUTH_URL to the public HTTPS origin and there is nothing else to do. Because ORIGIN is then explicit, adapter-node never consults the forwarded headers at all — so unlike a bare SvelteKit deploy, you do not need PROTOCOL_HEADER and HOST_HEADER, and forgetting them is not the cause of a 404 here:

AUTH_URL=https://cms.example.com     # what the browser sees, not the container port

The proxy still has to forward Host and the original protocol for logging and redirects to look right, which every default configuration already does. What it must not do is rewrite the path — the app expects to be served at the root of its origin, not under a subpath.

Before you go public

Turn the demo seed off. Both templates ship example content and seed it on first run against an empty database — which a fresh production deploy is. There is no development-only guard: the check is APHEX_SEED !== 'false', so unless you say otherwise your live site launches with the sample pages, and the website template launches with its whole demo blog.

APHEX_SEED=false

Set it before the first boot. Once the content exists, turning it off doesn't remove it — you delete the documents in the admin. Keep the seed only if you're deploying a demo on purpose.

Claim the instance. The first account to sign up becomes super admin. On a public URL that is a race against anyone who finds /login, so either sign up the moment the service is live, or decide it in advance:

APHEX_BOOTSTRAP_EMAIL=[email protected]   # only this address can claim it
# …or require a one-time code printed to the server log at first boot:
APHEX_BOOTSTRAP_CLAIM_CODE=true

Set up email. RESEND_API_KEY plus APHEX_EMAIL_FROM (an address on a domain verified with Resend). Until email works there is no password reset and no way to invite anyone — the account you created is the only way in, and losing its password means losing the instance.

Then turn on email verification. AUTH_REQUIRE_EMAIL_VERIFICATION=true. In this order — set it before email works and the first sign-up can't complete, because nothing can deliver the verification message.

Point the health check at /healthz. It reports database and storage adapter health as 200 or 503, rather than "the process is alive". All the bundled configs already do this.

Check your backups exist. Not that they run — that you have restored one. See Operations.

Environment variables

The template's .env.example is the authoritative list and is grouped for this purpose: required, local defaults, optional. What follows is the production view of the same thing.

Required

VariableWhat it does
AUTH_SECRETSigns session cookies and API keys. 32+ random bytes. Stable forever.
AUTH_URLThe app's public origin. Auth callbacks and every link inside outgoing email.

BETTER_AUTH_SECRET / BETTER_AUTH_URL are accepted as aliases for backwards compatibility. AUTH_TRUSTED_ORIGINS (the CSRF allowlist, comma-separated) defaults to AUTH_URL; set it only when another origin calls this app's auth routes.

State

VariableDefaultNotes
APHEX_SQLITE_URLfile:.aphex/base.dbPut it on a mounted volume. libsql://… for Turso.
DATABASE_AUTH_TOKENTurso only.
APHEX_DATABASEsqlitepostgres switches adapters — no code change.
DATABASE_URLPostgres. Or PG_HOST/PG_PORT/PG_USER/PG_PASSWORD/PG_DATABASE.
APHEX_DB_AUTO_MIGRATEtrueStops the app migrating on boot. Postgres + several replicas.
APHEX_SKIP_MIGRATEStops the entrypoint migrating. Set both, together, or neither.
APHEX_UPLOADS_DIR./uploadsLocal storage path. Volume, or use S3_* instead.

Object storage

All four of the first group must be set together — with any missing, the app silently stays on local disk.

VariableNotes
S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEYAny S3-compatible bucket — R2, S3, MinIO.
S3_PUBLIC_URLWhat end users see in <img src="…">.
S3_CDN_URLCustom CDN domain; overrides S3_PUBLIC_URL.

Jobs, email, secrets

VariableNotes
APHEX_EMBEDDED_WORKERtrue runs the queue in-process. Single container only.
APHEX_WORKER_SECRETGates POST /api/internal/workers/run. Unset → the endpoint 404s.
RESEND_API_KEYLeave unset rather than placeholder — a bad key fails silently at send.
APHEX_EMAIL_FROMAcme <[email protected]>, on a verified domain.
AUTH_REQUIRE_EMAIL_VERIFICATIONOff by default. Turn on only once email works.
APHEX_SECRET_ENCRYPTION_KEYEncrypts plugin secrets at rest. Rotating it orphans stored secrets.
APHEX_ASSET_SIGNING_SECRETSigns /media/:id/:filename URLs. Unset → private assets stay session-only.
APHEX_BOOTSTRAP_EMAILRestricts the super-admin claim to one address.
APHEX_SEEDfalse starts with no example content.

Three secrets must stay stable for the life of the instance: AUTH_SECRET (rotating signs everyone out and kills every API key), APHEX_SECRET_ENCRYPTION_KEY (rotating makes stored plugin secrets undecryptable), and APHEX_ASSET_SIGNING_SECRET (rotating invalidates every signed URL already handed out). Generating them in a platform's env editor and nowhere else is how they get lost.

See also

Edit on GitHub

Last updated on