Two official templates are published, one per image channel:
| Channel | Tracks | Deploy |
|---|---|---|
| main (stable) | :main |
|
| develop (latest builds) | :develop |
This guide covers deploying Roomote on Railway — either
through an official Roomote template or by composing the services manually.
Both templates are mirrored from the same maintained service specification,
template.yaml, and differ only in which image alias the
five app services track; everything in this guide applies to both. Railway
does not read that file directly, but it is the source of truth that
maintainers mirror into Railway's Template Composer.
For single-host Docker deployments, use the one-command installer or the Compose paths in SELF_HOSTING.md instead.
- No Caddy edge. Railway terminates HTTPS and gives every service its own
public domain. The web app and the API run on separate origins, so
TRPC_URLpoints at the api service's public domain with no/_roomote-apipath prefix. GitHub webhooks and hosted-sandbox workers call that API origin directly, and Slack webhooks arrive at the web origin and are proxied to the API internally. - No Docker socket. The
dockersandbox provider cannot run on Railway. Task execution must use a hosted sandbox provider: Modal (template default), E2B, or Daytona. Those only need outbound HTTPS plus API credentials. The template setsEXCLUDED_COMPUTE_PROVIDERS=dockerso the unusable provider never appears in setup or sandbox selection. - No openssl provisioning step. The template sets
R_AUTO_GENERATE_KEYS=true, so Roomote generates theJOB_AUTH_*/PREVIEW_AUTH_*P-256 keypairs at first boot and persists them encrypted (withENCRYPTION_KEY) in Postgres. Every other secret is a random string that Railway's${{secret(n)}}template function generates. - No template-level shared variables. Railway's Template Composer has no
environment-level shared-variable store, so every value that must match
across services is defined once on the api service and referenced from
the other services as
${{api.NAME}}. Never paste${{secret(n)}}into more than one service: each occurrence generates a different value, andENCRYPTION_KEYmust be identical everywhere. - Provider credentials live in the app, not the template. The template
ships with zero required deploy-time inputs. Modal tokens and the model
provider API key are entered in the
/setupwizard (or Settings) after first boot and stored encrypted in Postgres. The one optional prompt on the deploy screen isR_APP_URL, for deployers who want a custom domain from the start (see Attaching a custom domain). - Live previews are off by default, but the proxy ships ready. Preview
subdomains need a wildcard domain, and Railway-generated service domains
are single-label. The
preview-proxyservice is part of the template and boots healthy with thePREVIEW_*variables empty; enabling previews later is a wildcard custom domain plus three variable values (see below). Everything else works without them.
- A Railway account.
- A hosted sandbox account: Modal (default), E2B, or Daytona.
- A model provider API key, for example OpenRouter (entered in the setup wizard, not at deploy time).
Both published images (ghcr.io/roocodeinc/roomote-app and
ghcr.io/roocodeinc/roomote-worker) are public. Railway pulls the app image
anonymously, and hosted sandbox providers (Modal's remote builder,
E2B/Daytona worker builds) pull the worker image anonymously; no registry
credentials or MODAL_REGISTRY_USERNAME/MODAL_REGISTRY_PASSWORD are
needed.
Each template tracks one mutable channel alias, which the publish workflow moves on every matching build:
- The main-channel template tracks
:main, moved on every build of themainbranch (each build also publishes an immutablemain-<sha>tag). This is the stable choice. - The develop-channel template tracks
:develop, moved on every build of thedevelopbranch (immutable tag:develop-<sha>). Taggedv*releases additionally move:latest.
Nothing else in either template encodes a version:
- The images bake
RELEASE_VERSIONat build time, and the app derivesDOCKER_WORKER_IMAGEandMODAL_BASE_IMAGE_REFfrom it when those are unset. Set them only to override the derived worker image. - The controller reads the worker release version from the
VERSIONfile insideworker-current.tar.gz, soDOCKER_WORKER_RELEASE_PATHis a constant.
To pin instead (recommended for production deployments): put the same
immutable tag (v*, main-<sha>, or develop-<sha>) in the five
app-service image fields. No other edits are needed — the derived values
follow the image.
<channel> below is main or develop, per template.
| Railway service | Source | Start command | Public domain | Healthcheck |
|---|---|---|---|---|
Postgres |
Railway managed PostgreSQL | — | no | managed |
Redis |
Railway managed Redis | — | no | managed |
minio |
pinned minio/minio + /data |
minio server /data --console-address :9001 |
yes (HTTP proxy port 9000) | — |
web |
roomote-app:<channel> |
/roomote/.docker/app/entrypoint.sh web |
yes (HTTP proxy port 8080) | /health |
api |
roomote-app:<channel> |
/roomote/.docker/app/entrypoint.sh api |
yes (HTTP proxy port 8080) | /health/liveness |
controller |
roomote-app:<channel> |
/roomote/.docker/app/entrypoint.sh controller |
no | — |
bullmq |
roomote-app:<channel> |
/roomote/.docker/app/entrypoint.sh bullmq |
no | — |
preview-proxy |
roomote-app:<channel> |
/roomote/.docker/app/entrypoint.sh preview-proxy |
yes (HTTP proxy port 8080) | /health |
Railway's custom start command bypasses the image entrypoint and executes
the command directly, so the full /roomote/.docker/app/entrypoint.sh <service>
form is required — a bare web fails with The executable `web` could not be found. The same applies to minio (minio server ..., not server ...)
and to the api pre-deploy command below.
- Rename the minio service to exactly
minio: the defaultminio/minioname breaks${{minio.RAILWAY_PRIVATE_DOMAIN}}references. - Set the api service's pre-deploy command to
/roomote/.docker/app/entrypoint.sh db-migrateso schema migrations run before each new deploy starts serving. On a fresh project the other app services boot in parallel with that first migration pass and wait for it with bounded backoff (up to ~90 seconds, logging one[auth-keypairs]waiting-for-migrations line per retry) instead of crash-looping; Railway's restart policy still recovers them in the rare case migrations take longer. - The HTTP proxy port becomes the Railway-injected
PORT, which all app services honor. Any value works for web/api (the template uses 8080); the minio proxy must target 9000 (the S3 API — the console on 9001 is not exposed).
The api service is the anchor: it defines every shared value once, and
the other app services reference them with ${{api.NAME}}.
api:
R_APP_ENV=production
ROOMOTE_DOCKER_LOAD_ENV_FILE=false
R_AUTO_GENERATE_KEYS=true
ENCRYPTION_KEY=${{secret(32)}}
ARTIFACT_SIGNING_KEY=${{secret(32)}}
DASHBOARD_PASSWORD=${{secret(24)}}
S3_SECRET_ACCESS_KEY=${{secret(32)}}
S3_ACCESS_KEY_ID=roomote
S3_REGION=us-east-1
S3_BUCKET_ARTIFACTS=roomote-artifacts
S3_AUTO_CREATE_BUCKET=true
SETUP_TOKEN=${{secret(32)}}
R_PING_BASE_URL=https://ping.roomote.dev
PREVIEW_PROXY_BASE_URL=
NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL=
PREVIEW_DOMAINS=
DEFAULT_COMPUTE_PROVIDER=modal
EXCLUDED_COMPUTE_PROVIDERS=docker
DATABASE_URL=${{Postgres.DATABASE_URL}}
REDIS_URL=${{Redis.REDIS_URL}}
R_APP_URL=https://${{web.RAILWAY_PUBLIC_DOMAIN}}
TRPC_URL=https://${{api.RAILWAY_PUBLIC_DOMAIN}}
S3_ENDPOINT=http://${{minio.RAILWAY_PRIVATE_DOMAIN}}:9000
S3_PRESIGN_ENDPOINT=https://${{minio.RAILWAY_PUBLIC_DOMAIN}}web, bullmq, and preview-proxy (identical block):
R_APP_ENV=${{api.R_APP_ENV}}
ROOMOTE_DOCKER_LOAD_ENV_FILE=${{api.ROOMOTE_DOCKER_LOAD_ENV_FILE}}
R_AUTO_GENERATE_KEYS=${{api.R_AUTO_GENERATE_KEYS}}
ENCRYPTION_KEY=${{api.ENCRYPTION_KEY}}
ARTIFACT_SIGNING_KEY=${{api.ARTIFACT_SIGNING_KEY}}
DASHBOARD_PASSWORD=${{api.DASHBOARD_PASSWORD}}
S3_SECRET_ACCESS_KEY=${{api.S3_SECRET_ACCESS_KEY}}
S3_ACCESS_KEY_ID=${{api.S3_ACCESS_KEY_ID}}
S3_REGION=${{api.S3_REGION}}
S3_BUCKET_ARTIFACTS=${{api.S3_BUCKET_ARTIFACTS}}
SETUP_TOKEN=${{api.SETUP_TOKEN}}
R_PING_BASE_URL=${{api.R_PING_BASE_URL}}
PREVIEW_PROXY_BASE_URL=${{api.PREVIEW_PROXY_BASE_URL}}
NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL=${{api.NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL}}
PREVIEW_DOMAINS=${{api.PREVIEW_DOMAINS}}
DEFAULT_COMPUTE_PROVIDER=${{api.DEFAULT_COMPUTE_PROVIDER}}
EXCLUDED_COMPUTE_PROVIDERS=${{api.EXCLUDED_COMPUTE_PROVIDERS}}
DATABASE_URL=${{Postgres.DATABASE_URL}}
REDIS_URL=${{Redis.REDIS_URL}}
R_APP_URL=${{api.R_APP_URL}}
TRPC_URL=${{api.TRPC_URL}}
S3_ENDPOINT=${{api.S3_ENDPOINT}}
S3_PRESIGN_ENDPOINT=${{api.S3_PRESIGN_ENDPOINT}}controller: the same block plus
DOCKER_WORKER_RELEASE_PATH=/roomote/releases/worker-current.tar.gzThe worker release archive is baked into the app image, and the controller
uploads it into hosted sandboxes at spawn time — no shared volume is
needed. The version-less worker-current.tar.gz name works because the
controller reads the release version from the VERSION file inside the
archive. Do not leave DOCKER_WORKER_RELEASE_PATH unset: without it the
controller falls back to fetching GitHub worker releases, which only exist
for tagged v* releases — not for develop or main branch builds.
minio:
MINIO_ROOT_USER=${{api.S3_ACCESS_KEY_ID}}
MINIO_ROOT_PASSWORD=${{api.S3_SECRET_ACCESS_KEY}}Notes:
R_APP_URLon api is the single canonical-origin knob: it is the URL users browse, and web/controller/bullmq/preview-proxy reference${{api.R_APP_URL}}rather than repeating the value. It is also the template's one optional deploy-time prompt — the deploy screen shows it pre-filled with the generated-domain reference so a custom domain can be entered before first boot. Do not setR_PUBLIC_URL— it is optional and the app falls back toR_APP_URLeverywhere it would apply. See Attaching a custom domain.- Leave
DOCKER_WORKER_IMAGEandMODAL_BASE_IMAGE_REFunset. The app derives both from theRELEASE_VERSIONbaked into the running image, so they always match the deployed build. Setting them explicitly overrides the derivation and silently pins the worker to whatever version the value encodes — the exact multi-place version bump this template design removes. MODAL_TOKEN_ID,MODAL_TOKEN_SECRET, and model provider keys such asOPENROUTER_API_KEYare not template variables. Enter them in the/setupwizard (or Settings → Sandboxes / Models) after first boot; they are stored encrypted in Postgres. Setting them as env vars still works and takes precedence, but is unnecessary.SETUP_TOKENgates the pre-auth/setupwizard and is required: the app refuses tokenless first-admin bootstrap everywhere except local development, so a deployment without it cannot complete setup at all. The template generates it with${{secret(32)}}. Visitors to/setupare prompted for the token, which the operator copies from the api service's Variables tab in the deployed project. The URL form/setup?token=<value>also works.R_PING_BASE_URLis the endpoint for Roomote's anonymous analytics and version checks (the image default ishttps://ping.roomote.dev; the template points at the Roomote ping service). Admins can opt out of anonymous analytics in the setup wizard or Settings → Misc; version checks ignore that setting.- The three
PREVIEW_*variables ship empty (mark them optional in the Template Composer so the deploy screen does not prompt for them). Roomote and the preview-proxy service boot fine with them empty; previews report as not configured in Settings → Live Previews until the values are filled in on api (see Enabling live previews). - Leave
JOB_AUTH_*andPREVIEW_AUTH_*unset —R_AUTO_GENERATE_KEYS=truemanages them. If you later provide explicit env values, they take precedence over the persisted keypairs.
MinIO does not auto-create buckets, so the template sets
S3_AUTO_CREATE_BUCKET=true on api: at boot, the api creates the artifacts
bucket when it is missing. Nothing to do after the first deploy — watch for
[artifacts-bucket] Created S3 bucket ... in api's logs.
To create it manually instead (or if the automatic creation warned in the
logs), run once from any machine with
mc:
mc alias set roomote https://<minio-public-domain> roomote <S3_SECRET_ACCESS_KEY>
mc mb --ignore-existing roomote/roomote-artifactsThe generated S3_SECRET_ACCESS_KEY value is on the api service's Variables
tab in the deployed project.
Alternatively, skip bundled MinIO entirely and point the S3 values at an
external S3-compatible store (AWS S3 or Cloudflare R2, with S3_REGION=auto
for R2). Roomote uses path-style addressing, and S3_PRESIGN_ENDPOINT must
be reachable from hosted-sandbox workers. On an external store, either
pre-create the bucket and remove S3_AUTO_CREATE_BUCKET, or keep the flag
if the configured credentials are allowed to create buckets (the api only
logs a warning when creation fails).
- Wait for
webandapito report healthy. On a brand-new project the first boot also generates and persists the auth keypairs (watch for[auth-keypairs] Generated ...in an app service's logs — whichever app service boots first wins the race and generates them). - Open
https://<web-domain>/setupand paste theSETUP_TOKENvalue from the api service's Variables tab into the wizard's token step (or append?token=<SETUP_TOKEN>to the URL). If you entered a custom domain in theR_APP_URLprompt at deploy time, attach that domain to the web service and finish DNS first, then open/setupon the custom domain — the generated domain will reject auth with403 Invalid origin. - Create the founding admin account (email/password works immediately; Slack or Microsoft sign-in can be added later).
- Connect GitHub with Create GitHub App — the manifest flow derives the
callback and webhook URLs from
R_APP_URLandTRPC_URL, so no manual URL entry is needed. - Enter the sandbox provider credentials (Modal token pair for the default) and the model provider key when the wizard asks. When swapping to E2B or Daytona instead, the wizard and Settings → Sandboxes can build the E2B template or Daytona snapshot in your provider account after credentials are saved.
- Pick repositories, create an environment, and run a small task end to end (the SELF_HOSTING.md verification checklist applies from step 2 onward).
By default the template boots on Railway-generated domains, and
R_APP_URL — the origin users browse — derives from the web service's
generated domain. A custom domain can be set either at deploy time (through
the template's one optional prompt) or after deploy (a one-variable edit).
Setting it at deploy time is preferable when you already own the domain:
everything the setup wizard registers — in particular the GitHub App's OAuth
callback and webhook URLs — derives from the canonical origin, so getting it
right before /setup avoids reconnecting integrations later.
At deploy time. The deploy screen shows R_APP_URL on the api
service pre-filled with the generated-domain reference
(https://${{web.RAILWAY_PUBLIC_DOMAIN}}). Replace it with your domain, for
example https://app.example.com. Then, after the project deploys:
- Add
app.example.comas a custom domain on the web service and complete the DNS setup. - Open
/setupon the custom domain — not the generated one. OnceR_APP_URLpoints at the custom domain, the generated web domain rejects signup and login with403 {"error":"Invalid origin"}, which is expected: only the canonical origin is trusted.
After deploy. When you attach a custom domain to the web service on a
running deployment, Railway does not update RAILWAY_PUBLIC_DOMAIN, so
the app keeps treating the generated domain as canonical. The symptom is a
working dashboard that rejects signup, login, and OAuth flows with
403 {"error":"Invalid origin"}: the browser sends the custom domain as its
Origin, and the auth layer only trusts R_APP_URL. The fix is a
one-variable edit, because the app services all reference
${{api.R_APP_URL}}:
- Add the custom domain (for example
app.example.com) to the web service in Railway and complete the DNS setup. - On the api service, set
R_APP_URL=https://app.example.comand accept Railway's prompt to redeploy the app services.
Everything derived from the canonical origin follows: auth origins, OAuth callback URLs, and the absolute links Roomote posts to Slack, GitHub, and other integrations. Connectors registered before the switch (for example a GitHub App created by the wizard) keep the callback URLs they were created with, so reconnect or update those in their provider settings if you change the domain after onboarding.
Leave TRPC_URL on the api service's own domain — hosted-sandbox workers
and webhooks call it directly, and it never needs to match the domain users
browse. (A custom domain on the api or MinIO services works the same way if
you want one: they are separate values on api, TRPC_URL and
S3_PRESIGN_ENDPOINT.)
The preview-proxy service is already part of the template and boots
healthy with previews unconfigured. Enabling previews needs a wildcard
domain, which requires a domain you control:
- Point
previews.<your-domain>and*.previews.<your-domain>at Railway as custom domains on the preview-proxy service, following Railway's wildcard-domain docs. - Fill in the three empty
PREVIEW_*values on api:PREVIEW_PROXY_BASE_URL=https://previews.<your-domain>,NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL=https://previews.<your-domain>, andPREVIEW_DOMAINS=previews.<your-domain>, then redeploy the app services. The other services already reference${{api.*}}for all three; theNEXT_PUBLIC_variant is what the web client uses to build preview links, so it must reach thewebservice. - Opt in from Settings → Live Previews, which validates the wildcard hostname and enables previews per deployment and environment.
- Template edits do not propagate. A deployed project is a snapshot; Railway's template-update notifications only exist for GitHub-repo-based templates, not Docker-image-based ones like this. Template changes affect new deploys only.
- Upgrade a deployment on a channel alias (
:mainor:develop) by redeploying the five app services — they pull the current alias, and everything version-coupled derives from the new image. On an immutable pin, bump the tag in the five image fields first. The api service'sdb-migratepre-deploy applies any schema changes, and the auto-generated keypairs persist in Postgres, so sessions, job tokens, and preview tokens survive redeploys. These redeploys can be automated against Railway's GraphQL API — see Auto-deploying every develop build. - Back up the Railway Postgres database (Railway backups or
pg_dump) and the MinIO volume or external bucket. Everything else is reproducible from config. - Costs split three ways: Railway hosts the control plane (web, api, preview-proxy, controller, bullmq, Postgres, Redis, MinIO), while task execution bills through your sandbox provider (Modal/E2B/Daytona) and model usage bills through your model provider.
Railway never redeploys on its own when a mutable alias like :develop or
:main moves, so a deployment tracking a channel alias only picks up new
builds when the app services are redeployed.
Those redeploys can be automated from any CI system against Railway's public
GraphQL API using a project token (scoped to a single environment).
Resolve the environment from the token itself
(query { projectToken { environmentId } }), find the services whose image
starts with ghcr.io/<owner>/roomote-app, then for each issue
serviceInstanceUpdate pointing at an immutable tag (e.g.
develop-<short-sha> or v1.2.3) followed by serviceInstanceDeploy. Both
mutations are idempotent, so a retried or partially failed run is safe to
re-run, and pinning immutable tags makes rollback a tag switch — point the
image field back at an older tag and deploy.
Two published templates mirror the one spec in
template.yaml: the main-channel template
(railway.com/deploy/Rj2cFo) and the develop-channel template
(railway.com/deploy/bP3Lsu). They differ only in the app image alias
(:main vs :develop); everything else must stay identical. When the spec
changes, edit template.yaml first, then mirror the change into both
published templates through Railway's Template Composer (the Templates list
has a Duplicate action for seeding a new template, but edits to existing
templates are applied to each by hand). Re-run the
first-boot verification on a scratch Railway project before publishing an
update (one scratch run on either channel covers a change that does not
touch the image fields). When the change touches reference variables — in
particular the ${{api.*}} references to values that are themselves
references, like R_APP_URL — also open each app service's Variables
tab on the scratch project and confirm the resolved values are real URLs,
not literal ${{...}} strings.
The R_APP_URL deploy-time prompt needs its own check on the scratch
deploy: on the deploy screen, open the api service's Configure step and
expand its pre-configured environment variables — R_APP_URL must
appear as an editable field with the description from template.yaml and
the reference default pre-filled. Confirm that leaving the default still
resolves to the generated web domain after deploy, and that overriding it
with a test value reaches the app services as that literal value.