Railway
Two one-click templates with nothing to fill in — SQLite on a volume, or Postgres with object storage. Usage-billed, cheapest to start.
Two one-click templates, both deploying the website template with nothing to fill in — services are provisioned and wired together, secrets are generated, and the volume is attached for you.
SQLite + volume
One service, one volume holding both the database and the uploads. Cheapest and simplest, and a single instance — so deploys are a brief stop-then-start rather than zero-downtime.
Postgres + object storage
Three services. No volume on the app, so it scales past one replica and deploys with no downtime.
These are a starting point, not a demo. Build your own version locally with pnpm create aphex, push it to GitHub, then point app service → Settings → Source at your repository. The
database, uploads and domain stay exactly where they are — only the code changes hands. Do it
before you rely on the site: until you switch, the service redeploys whenever the Aphex template
is updated, so your site would follow our release schedule rather than yours.
Starting from your own repo instead? Push it to GitHub and use New Project → Deploy
from GitHub repo. The railway.json came with the template, so there is nothing to
add, and this path works with a private repo — which the buttons can't, since a
deploy link only reaches public ones.
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=trueKeep the three secrets somewhere durable — rotating AUTH_SECRET signs everyone out
and invalidates every API key.
Generate a domain — Settings → 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.
Email is not configured
Neither template sets up email, and nothing in the deploy will tell you so. Until you add it:
- no password reset — the account you signed up with is the only way in, and losing its password means losing the instance
- no team invitations — you can't add anyone
- no email verification — anyone signing up can use an address they don't own
That is survivable while you are evaluating and not something to leave in place.
It is left out of the template on purpose: a blueprint can't leave a value blank, and a placeholder API key is worse than none at all — the app builds a live client around it and every send then fails silently.
Setting it up
Two variables, on the app service:
RESEND_API_KEY=re_…
APHEX_EMAIL_FROM=Acme <[email protected]>The from address has to be on a domain you have verified with Resend — a DNS
record they give you. Sending from an unverified domain is rejected, which looks
identical to a missing key from inside the app.
Then send yourself a password reset from /login to confirm mail actually arrives.
Once mail works, turn on verification
AUTH_REQUIRE_EMAIL_VERIFICATION=trueIn that order. Set it before email works and the first sign-up cannot complete, because nothing can deliver the verification message — leaving nobody able to sign in at all.
Using something other than Resend? Any SMTP server works through the Nodemailer adapter — see Configuration → email.
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.
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.
Regions
Put every service in the same region. Railway sets it per service, so moving the app leaves its Postgres and its volume behind in the old one.
Nothing errors when they diverge — the site just gets slow, and dramatically so, because a page render makes dozens of sequential round trips to the database. On a real deploy the same homepage took 7.3s split across regions and 0.15s co-located, with no other change.
/healthz does one database round trip, so it is the quickest way to check: under 100ms
is healthy, a few hundred means the database is far away.
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. Railway injects its own PORT — typically 8080 — and the image listens on
whatever it is given, falling back to 3000 only when nothing injects one. So the
number to put in Settings → Networking is the one Railway is already using, not the
Dockerfile's 3000: check the service's variables and match them. Overriding PORT
yourself works too, as long as the proxy's target port matches it.
Logs show EACCES: permission denied, mkdir '/data/...'. Railway mounts volumes as
root, and the entrypoint handles that itself: starting as root it creates the
directories named by APHEX_SQLITE_URL and APHEX_UPLOADS_DIR, chowns them to node,
and only then drops privileges. So this shouldn't happen — if it does, something added
a USER directive to the Dockerfile, which starts the container as non-root and skips
that step. Drop the directive.
Don't reach for RAILWAY_RUN_UID=0. It's the usual Railway workaround for volume
permissions and it's the wrong one here — it hands root back to the server process and
undoes the privilege drop the entrypoint just performed.
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.
Note that adding a volume after the first deploy doesn't rescue anything already
written — Railway mounts it empty over that path.
Every page takes seconds, and nothing is erroring. The services are probably in different regions. Railway sets the region per service, so moving the app without moving its database leaves every query crossing a continent — and a page render makes dozens of them, sequentially. See Regions above.
The logs warn that rate limiting can't determine a client IP. Better Auth needs the
real caller address, and behind a proxy it has to be read from a header. Railway is
detected automatically, so this should only appear on platforms that aren't — set
AUTH_IP_ADDRESS_HEADERS (usually x-forwarded-for, cf-connecting-ip behind
Cloudflare). Until it resolves, every caller shares one rate-limit bucket per endpoint,
so a single password-reset request throttles everyone.
Last updated on