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:
Deploy to Render →
One container, one disk, ~$8/month. The most predictable of the two.
Deploy on Railway →
Cheapest to start, usage-billed. One manual step: attaching the volume.
Which path
The real question is not which host, it's where your database and your uploaded media live. Everything else follows from that.
| Path | Database | Media | Cost/month | Good when |
|---|---|---|---|---|
| Render | SQLite on a disk | The same disk | ~$8 | You want a blueprint and a URL, and one instance is plenty. |
| Railway | SQLite on a volume | The same volume | ~$5, usage | Same, cheaper, if you don't mind adding the volume by hand. |
| Coolify / Dokploy | SQLite or Postgres | Volume or S3 | Your VPS | You already have a server and want everything on it. |
| Docker anywhere | Your choice | Your choice | Your infra | Fly, a bare VPS, Kubernetes, a host not listed here. |
| Other platforms | Postgres or Turso | S3, required | Varies | Vercel, 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 volume | Postgres + S3 | |
|---|---|---|
| Deploys | Stop-then-start — brief downtime every release, because a volume attaches to one instance | Rolling, no downtime |
| Replicas | One, by construction | As many as you like |
| Backups | A file you copy yourself, or Litestream you set up | Point-in-time recovery from any managed provider |
| Writes | Serialized — editors contend with the job queue's own writes | Concurrent |
| Reach | Inside the container | Any connection string: psql, BI, dashboards |
| Cost | ~$5–8/month, one container | Add 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=trueThat 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.comAll 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 48Generate 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/uploadsOr 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 answerRunning 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.
| Variable | Who reads it | Default |
|---|---|---|
AUTH_URL | Better Auth | Derived from the platform by the entrypoint |
ORIGIN | adapter-node | Derived from AUTH_URL by the entrypoint |
AUTH_TRUSTED_ORIGINS | Better Auth | AUTH_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.comProtocol 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.comOnly 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 portThe 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=falseSet 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=trueSet 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
| Variable | What it does |
|---|---|
AUTH_SECRET | Signs session cookies and API keys. 32+ random bytes. Stable forever. |
AUTH_URL | The 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
| Variable | Default | Notes |
|---|---|---|
APHEX_SQLITE_URL | file:.aphex/base.db | Put it on a mounted volume. libsql://… for Turso. |
DATABASE_AUTH_TOKEN | — | Turso only. |
APHEX_DATABASE | sqlite | postgres switches adapters — no code change. |
DATABASE_URL | — | Postgres. Or PG_HOST/PG_PORT/PG_USER/PG_PASSWORD/PG_DATABASE. |
APHEX_DB_AUTO_MIGRATE | true | Stops the app migrating on boot. Postgres + several replicas. |
APHEX_SKIP_MIGRATE | — | Stops the entrypoint migrating. Set both, together, or neither. |
APHEX_UPLOADS_DIR | ./uploads | Local 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.
| Variable | Notes |
|---|---|
S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | Any S3-compatible bucket — R2, S3, MinIO. |
S3_PUBLIC_URL | What end users see in <img src="…">. |
S3_CDN_URL | Custom CDN domain; overrides S3_PUBLIC_URL. |
Jobs, email, secrets
| Variable | Notes |
|---|---|
APHEX_EMBEDDED_WORKER | true runs the queue in-process. Single container only. |
APHEX_WORKER_SECRET | Gates POST /api/internal/workers/run. Unset → the endpoint 404s. |
RESEND_API_KEY | Leave unset rather than placeholder — a bad key fails silently at send. |
APHEX_EMAIL_FROM | Acme <[email protected]>, on a verified domain. |
AUTH_REQUIRE_EMAIL_VERIFICATION | Off by default. Turn on only once email works. |
APHEX_SECRET_ENCRYPTION_KEY | Encrypts plugin secrets at rest. Rotating it orphans stored secrets. |
APHEX_ASSET_SIGNING_SECRET | Signs /media/:id/:filename URLs. Unset → private assets stay session-only. |
APHEX_BOOTSTRAP_EMAIL | Restricts the super-admin claim to one address. |
APHEX_SEED | false 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
Operations
Migrations on deploy, the job worker, health checks, backups, caching, upgrades.
Docker
The bundled Dockerfile and entrypoint, line by line. Compose, VPS, Fly.
Database
SQLite vs Postgres, migrations, RLS, multi-tenancy.
Storage
Local vs S3, signed URLs, image metadata, custom adapters.
Configuration
Every option in aphex.config.ts.
Authentication
Auth vars, sign-up flow, organization invitations.
Last updated on