How to translate a production docker-compose into a Railway project deployed from GitHub. Railway doesn't run Compose directly, so each service becomes its own Railway service wired through the private network and managed databases. A full step-by-step for a multi-service Grit app (API, admin, web, Postgres, Redis, migrations).
This guide translates docker-compose.prod.yml into a Railway project, deployed
from GitHub. Railway does not run docker-compose.yml files directly —
every service in the Compose file becomes its own Railway service, wired
together with Railway's private network and managed databases instead of
Traefik + Docker networks. Follow every step in order; later steps assume
earlier ones are done.
Reasons for the two differences you'll notice below:
No standalone migrate service. Railway has no depends_on: condition: service_completed_successfully, so a one-shot container that
"runs once then exits" isn't a first-class concept the way it is in
Compose. The correct Railway feature for this is a Pre-Deploy Command
on the api service — it runs in its own container, before the new api
version starts serving traffic, and the deploy is aborted if it fails.
Postgres/Redis become managed plugins, not containers. Railway's
guidance is to use its managed database services instead of raw
postgres:16-alpine / redis:7-alpine images — you get backups,
connection pooling and a dashboard for free, and you no longer need to
manage volumes for them yourself.
Confirm you're on the branch you intend to deploy (commonly main), and
that it's pushed to GitHub. Railway builds from GitHub, so uncommitted
local changes will not be deployed.
Confirm the three Dockerfiles exist at:
apps/api/Dockerfile
apps/admin/Dockerfile
apps/web/Dockerfile
Open apps/admin/Dockerfile and apps/web/Dockerfile and confirm they
declare ARG for every build-time variable the Compose file passes under
build.args (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL,
NEXT_PUBLIC_ADMIN_URL, THEME, SOCIAL_AUTH_ENABLED,
NEXT_PUBLIC_DEMO_LOGINS). Railway only forwards a variable into the
Docker build if the Dockerfile has a matching ARG line — without it,
the value you set in the Railway dashboard is silently ignored and the
bundle bakes in whatever the Dockerfile's default is.
Open apps/api/Dockerfile and confirm the ./migrate and ./seed
binaries referenced in the Compose command: are actually produced by
the build (i.e. they're part of the same image api runs from — not a
separate build stage that gets discarded).
Check .dockerignore at the repo root. Since admin and web build
with context: . (the whole repo), make sure .dockerignore excludes
node_modules, .next, .git, and e2e so the build context upload to
Railway stays small and fast.
You will not commit .env or .env.production — their values move
into Railway's Variables UI in Part 5–7. Keep them open locally for
reference while you copy values over.
Before deploying, create three DNS A records (or a CNAME once you
know Railway's target — see note below) pointing at wherever your traffic
will land:
Host
Purpose
sentex.gritcms.com
web — public shell + login
admin.sentex.gritcms.com
admin — staff admin panel
api.sentex.gritcms.com
api — Go API
Note on Railway specifically: unlike the Dokploy/Traefik setup in your
Compose file, Railway custom domains are attached as a CNAME to a
Railway-generated target (e.g. xxxx.up.railway.app), not an A record to a
static IP. You'll get the exact CNAME target in Part 8, after each service
has a Railway-generated domain to point at. It's fine to leave DNS for last
— just don't skip it, since WEB_DOMAIN / ADMIN_DOMAIN / API_DOMAIN
are baked into the frontend bundles at build time and mismatches show up as
CORS/CSP failures, exactly as the comments in your Compose file warn.
Storage stays on Cloudflare R2 and needs no DNS record here, same as in the
Compose file — only the R2_* variables need to be set (Part 5).
Go to railway.com/dashboard and log in
(or sign up) with GitHub — this makes the GitHub connection in the next
part one click instead of a separate OAuth flow.
Click + New Project.
Choose Empty Project. (Don't use "Deploy from GitHub repo" on this
screen — because this repo is a monorepo with multiple deployable
services and a non-JS apps/api alongside JS apps, Railway's automatic
monorepo importer won't produce the exact 4-service layout you need. It's
more reliable to add each service by hand in Part 4–7.)
Rename the project (top left, click the project name) to something like
sentex-production so it's unambiguous in your Railway dashboard.
If you have more than one Railway environment planned (e.g.
production vs staging), do this whole guide once per environment —
Railway environments are separate blank canvases under the same project.
This guide assumes a single production environment.
Railway provisions a Postgres instance immediately and creates a service
card named Postgres on the canvas. It automatically generates and
exposes these variables on that service (you don't set these — Railway
does): DATABASE_URL, DATABASE_PUBLIC_URL, PGHOST, PGPORT,
PGUSER, PGPASSWORD, PGDATABASE.
Click into the Postgres service → Variables tab and leave it as is.
You'll pull these values into api via reference variables in Part 5 —
don't retype the password anywhere.
No volume setup is needed here — managed databases handle their own
storage, unlike the postgres-data named volume in your Compose file.
If this is the first time connecting this GitHub account/org, authorize
the Railway GitHub App and grant it access to the sentex repository
(or "All repositories" if you're comfortable with that).
Select the sentex repository, then select the branch you're deploying
(e.g. main).
Railway creates a service and immediately tries to build it. It will
likely fail or pick the wrong Dockerfile at this point — that's expected,
since it just tried to build from the repo root. Continue to 6.2 before
worrying about that first failed build.
This matches build.context: ./apps/api in the Compose file. With the
root directory set, Railway will look for Dockerfile inside
apps/api/ automatically — you don't need to set a separate Dockerfile
path for this service.
Rename the service (click the service name at the top) to api, so its
private-network hostname becomes api.railway.internal.
Under Build, scroll to Watch Paths and add:
apps/api/**packages/**
This stops a commit that only touches apps/web or apps/admin from
triggering an unnecessary api rebuild.
Go to the Variables tab and add the following. Use Raw Editor to
paste several at once (KEY=VALUE per line). For anything that references
another Railway service, use Railway's reference-variable syntax
${{ServiceName.VARIABLE}} instead of hardcoding a value — it stays in
sync automatically if credentials rotate.
APP_ENV=production# Pulled from the managed Postgres plugin (Part 4) instead of a containerPOSTGRES_HOST=${{Postgres.PGHOST}}POSTGRES_PORT=${{Postgres.PGPORT}}POSTGRES_USER=${{Postgres.PGUSER}}POSTGRES_PASSWORD=${{Postgres.PGPASSWORD}}POSTGRES_DB=${{Postgres.PGDATABASE}}# Pulled from the managed Redis plugin (Part 5) instead of a containerREDIS_URL=${{Redis.REDIS_URL}}# Same public-origin / CORS logic as the Compose file, now pointed at# real domains instead of Docker network hostnamesAPP_URL=https://api.sentex.gritcms.comCORS_ORIGINS=https://sentex.gritcms.com,https://admin.sentex.gritcms.com
Then add every other application variable your .env.production defines
that isn't Postgres/Redis/routing related — for example your R2 storage
credentials, since those are unrelated to Compose networking and carry over
as plain values:
Copy across any remaining app-specific secrets (JWT signing keys, OAuth
credentials if social auth is enabled, mail provider keys, etc.) from your
local .env.production the same way — one KEY=VALUE line per variable.
Still in the api service, go to Settings → Deploy.
Find Pre-Deploy Command and set it to:
sh -c "./migrate && ./seed"
Wrap it in sh -c "..." exactly like this — because api builds from a
Dockerfile (not Railpack), Railway executes the pre-deploy command
directly rather than through a shell, so && needs an explicit shell to
be interpreted correctly. Without the sh -c wrapper the command fails
with an "exec format" style error.
Leave Custom Start Command as whatever apps/api/Dockerfile's
CMD/ENTRYPOINT already runs your compiled API binary with — you don't
need to override it, since the Compose file's api service doesn't
override command: either.
This reproduces the ordering guarantees from your Compose file
(migrate → seed → then api starts serving) without needing a
separate one-shot service: the pre-deploy command runs in its own
container on every deploy, and if it exits non-zero the new api
version never goes live. It is idempotent on your side already (the seed
skips when members exist), so redeploys are safe exactly as documented
in the Compose file's header comment.
Click Generate Domain if you want a temporary *.up.railway.app URL
to test with before the custom domain is live. This is optional — you
can also wait and go straight to the custom domain in Part 8.
You do not need to expose port 8080 manually the way expose: ["8080"] did in Compose — Railway detects the port your app listens on,
or you can pin it explicitly under Settings → Networking → Port.
Trigger a redeploy (Deploy button, top right) now that Root
Directory, Variables, and the Pre-Deploy Command are all set. Watch the
Deploy Logs — you should see the pre-deploy container run
./migrate && ./seed and exit 0, then the api container start.
Unlike api, the Compose file builds admin with context: . (the repo
root) but dockerfile: apps/admin/Dockerfile — it needs the repo root as
build context so it can see the shared packages/ workspace and root
pnpm-lock.yaml, but the Dockerfile itself lives one level down.
Go to Settings → Source.
Leave Root Directoryblank (i.e. the repo root) — do not set it
to apps/admin, or the build will lose access to packages/ and
pnpm-lock.yaml and fail during pnpm install.
Under Build Configuration, set Dockerfile Path to:
These map directly to the build.args block for admin in your Compose
file. Because apps/admin/Dockerfile declares them with ARG, setting
them as ordinary Variables on this service (not anything special) is
enough for Railway to forward them into the Docker build automatically —
that's what "Using variables at build time" means in Railway's Dockerfile
docs, as long as the ARG line exists in the Dockerfile.
Adjust THEME, SOCIAL_AUTH_ENABLED, and NEXT_PUBLIC_DEMO_LOGINS to your
actual production values — the ones above just mirror the safe defaults
called out in the Compose file's comments (social auth off, demo logins
off, since this stack has no OAuth provider configured). Remember these are
build-time: changing them later requires a new deploy/rebuild, not just
a service restart, exactly as your Compose file's comments warn.
Add any runtime-only variables admin needs beyond the build args (for
example, session secrets, if the admin app reads them at request time
rather than build time).
Under Settings → Networking, click Generate Domain for a
temporary test URL, or skip straight to Part 8 for the real domain.
Deploy the service and check Deploy Logs for a successful Next.js
start on port 3000.
Do this once api, admin, and web have each successfully deployed at
least once.
Open the api service → Settings → Networking → Custom Domain.
Enter api.sentex.gritcms.com and click Add.
Railway shows you a CNAME target (something like
xxxx.up.railway.app). Copy it.
In your DNS provider, create a CNAME record:
api.sentex.gritcms.com → xxxx.up.railway.app
(replacing the A record placeholder from Part 2).
Repeat steps 1–4 for admin (admin.sentex.gritcms.com) and web
(sentex.gritcms.com) on their respective services.
Wait for DNS to propagate, then confirm each domain in the Railway
dashboard shows a green "Active"/verified TLS status. Railway
provisions and renews the TLS certificate automatically once DNS
resolves correctly — you don't manage Let's Encrypt yourself the way the
Compose file's Traefik labels did.
Important: if you generated a domain, deployed, and then changed
NEXT_PUBLIC_API_URL / NEXT_PUBLIC_ADMIN_URL / NEXT_PUBLIC_WEB_URL to
match the final custom domains, you must trigger a fresh deploy of admin
and web after adding the custom domains — those values are baked into the
JS bundle at build time, so the bundle built against the temporary
*.up.railway.app URL will not automatically pick up the custom domain.
Postgres / Redis: open each service's Data tab (Postgres) or
Metrics tab and confirm they show as running/healthy.
api: open Deploy Logs, confirm you see the pre-deploy command run
./migrate && ./seed and exit cleanly, then the API start message. Hit
https://api.sentex.gritcms.com/<your health endpoint> with curl and
confirm a 200.
admin: visit https://admin.sentex.gritcms.com in a browser, open
dev tools → Network tab, and confirm requests go to
api.sentex.gritcms.com with no CORS errors in the console.
web: visit https://sentex.gritcms.com and repeat the same CORS/API
check.
Log in with the demo SACCO credentials seeded by the migrate/seed
step to confirm the database was actually populated.
Because every service is connected via GitHub Repo (not the CLI), the
day-to-day workflow is now:
Push a commit to the connected branch (e.g. main).
Railway's GitHub webhook triggers a build for every service whose
Watch Paths matched the changed files.
For api, the pre-deploy command reruns automatically on every deploy
— safe, since your migration/seed are idempotent.
Watch Deploy Logs per service if anything looks off; roll back to a
previous deployment from the service's Deployments tab if needed
(Railway keeps deployment history and supports one-click rollback).
You generally won't need the Railway CLI for this project's day-to-day
deploys — it's most useful for local railway run / railway variables
debugging against the same environment, or railway logs to tail a
service without opening the dashboard.