Aphex
Deployment

Railway

Deploy to Railway from the bundled railway.json — SQLite on a volume, or Railway's managed Postgres. Usage-billed, cheapest to start.

Railway builds the bundled Dockerfile and reads railway.json for the health check and restart policy. It's the cheapest of the one-click options to start on, with one manual step the others don't have: attaching the volume, which Railway does not create from a config file.

To deploy your own project: push it to GitHub, then use New Project → Deploy from GitHub repo and pick it. The railway.json came with the template, so there is nothing to add, and this path works with a private repo.

Just want to see one running? Use the same path as above — New Project → Deploy from GitHub repo — against aphex-base or aphex-website. That deploys the Aphex template repo, not your code: you can't change the content model and you can't push to what's running. A demo, not a starting point.

Setup

Deploy the repo, and let the first build finish. It will start, fail its health check and restart — that is expected until the volume exists, because with no volume there is nowhere durable for the database.

Add a volume mounted at /data. In the service: Settings → Volumes → Add Volume, mount path /data.

This is the step no config file can do for you, and skipping it is the mistake worth avoiding: without it the app still runs, writing the database and every upload into the container filesystem, and loses all of it on the next deploy. It looks like it works right up until it doesn't.

Set the variables in the service's Variables tab:

AUTH_SECRET=<openssl rand -base64 48>
APHEX_SECRET_ENCRYPTION_KEY=<openssl rand -base64 48>
APHEX_ASSET_SIGNING_SECRET=<openssl rand -base64 48>
APHEX_SQLITE_URL=file:/data/aphex.db
APHEX_UPLOADS_DIR=/data/uploads
APHEX_EMBEDDED_WORKER=true

Keep the three secrets somewhere durable — rotating AUTH_SECRET signs everyone out and invalidates every API key.

Generate a domainSettings → Networking → Generate Domain. Railway exposes it to the container as RAILWAY_PUBLIC_DOMAIN, and the entrypoint derives AUTH_URL and ORIGIN from it, so sign-in works without you setting a URL by hand.

Adding a custom domain later? Set AUTH_URL explicitly then — the derived value keeps following the railway.app hostname, and outgoing email links would keep pointing there.

Sign up at /login. The first account becomes super admin, so do it now, or set APHEX_BOOTSTRAP_EMAIL first to restrict the claim to your address.

Using Railway Postgres instead

Worth doing if you want more than one replica, rolling deploys, or a database you can connect to from elsewhere. Two clicks and two variables:

Add the database. In the project: New → Database → Add PostgreSQL. Railway provisions it and exposes its connection string to other services in the project.

Point the app at it, in the app service's Variables:

APHEX_DATABASE=postgres
DATABASE_URL=${{Postgres.DATABASE_URL}}

The ${{Postgres.DATABASE_URL}} reference is Railway's own syntax — it resolves to the database service's URL and follows it if the credentials rotate. Use the exact service name shown in your project if you renamed it.

DATABASE_URL, not DATABASE_PUBLIC_URL. Railway's dashboard offers both and they look interchangeable. The public one routes through Railway's TCP proxy — slower, and billed as egress — for traffic between two services in the same project that never needed to leave it. DATABASE_PUBLIC_URL is for external clients, like a local admin tool connecting from your laptop.

Then remove APHEX_SQLITE_URL. Migrations in drizzle/ apply on container start.

Deal with the media. The volume is now only holding uploads. Either keep it mounted at /data with APHEX_UPLOADS_DIR=/data/uploads — simple, but still pins you to one instance — or move media to a bucket and drop the volume entirely:

S3_ENDPOINT=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_URL=

All four of the first must be set together. With some but not all of them set the app falls back to local disk and logs a warning naming the ones it didn't find — worth reading the boot log after the first deploy, because uploads written to local disk here do not survive the next one.

If you scale past one replica, two things change, and both are quiet when you get them wrong.

The worker moves out of the app. Turn APHEX_EMBEDDED_WORKER off — each replica would otherwise run its own queue loop against the same queue — and replace it, rather than simply removing it:

APHEX_WORKER_SECRET=<openssl rand -base64 48>

That enables POST /api/internal/workers/run (it 404s while the secret is unset, so it is never an unauthenticated surface). Then drive it from a second Railway service on a cron schedule — same repo, Settings → Cron Schedule of * * * * *, with the start command:

curl -fsS -X POST "$AUTH_URL/api/internal/workers/run" \
  -H "Authorization: Bearer $APHEX_WORKER_SECRET"

Switching the embedded worker off without doing this leaves the queue with nothing draining it: scheduled publishes are accepted, report success, and never happen.

Migrations move to a pre-deploy step, so N containers don't race through the same DDL on rollout. That takes both switches, because they are separate code paths: APHEX_DB_AUTO_MIGRATE=false stops the app migrating on boot, and APHEX_SKIP_MIGRATE=true stops the container entrypoint doing it. Set only one and the other still runs.

See Operations → Background jobs.

Switching an instance that already has content moves nothing across — not the documents, not the media. Both switches are clean slates unless you migrate the data yourself, so make the call before there is anything worth keeping.

Costs

Railway is usage-billed rather than per-instance: you pay for the CPU, memory, volume and egress you actually use, against a monthly plan credit. A small CMS with a volume tends to sit at the bottom of that. Adding Postgres roughly doubles a small project's usage, since the database is a second always-on service.

The trade against Render: cheaper and more flexible, but you assemble it yourself — the volume, the domain and the variables are all manual, where Render's blueprint declares them.

Troubleshooting

Health check fails and the service restarts in a loop. Check the deploy logs. If they end at the entrypoint's AUTH_SECRET is not set message, the variable is missing — the container refuses to start rather than come up with unsigned sessions.

Sign-up returns a 404. No domain generated yet, so nothing set RAILWAY_PUBLIC_DOMAIN and the origin check fails. Generate the domain, or set AUTH_URL by hand.

The public URL says the service is unavailable. Railway is routing to the wrong port. The image listens on whatever PORT Railway injects, defaulting to 3000; if the service's public target port doesn't match, nothing reaches the app. Set the target port to 3000 under Settings → Networking, or set PORT explicitly and match it.

Logs show EACCES: permission denied, mkdir '/data/...'. Railway mounts volumes as root. The bundled image runs as root too, so this shouldn't happen — if it does, something added a USER directive to the Dockerfile. Either drop it or set RAILWAY_RUN_UID=0.

Everything vanished after a deploy. No volume, or APHEX_SQLITE_URL / APHEX_UPLOADS_DIR not pointing inside it. Verify the mount path is exactly /data.

Edit on GitHub

Last updated on