This guide covers deploying Roomote on Render as a
Blueprint. The maintained service specification lives in
render.yaml at the repository root — Render's deploy
button and Blueprint sync read it from there directly, which is why the
Blueprint file does not live in this directory.
For single-host Docker deployments, use the one-command installer or the Compose paths in SELF_HOSTING.md instead. For the other PaaS paths, see deploy/railway and deploy/coolify.
- No Caddy edge. Render terminates HTTPS and gives every web service its
own public
onrender.comdomain. The web app and the API run on separate origins, soTRPC_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 Render. Task execution must use a hosted sandbox provider: Modal (Blueprint default), E2B, or Daytona. Those only need outbound HTTPS plus API credentials. The Blueprint setsEXCLUDED_COMPUTE_PROVIDERS=dockerso the unusable provider never appears in setup or sandbox selection. - No openssl provisioning step. The Blueprint 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 agenerateValue: truevariable that Render generates at deploy time. - Shared secrets are defined once. The
roomote-sharedenvironment group holds every value that must match across services; Render generates each secret once and all services referencing the group see the same value, soENCRYPTION_KEYis identical everywhere. The MinIO password is the one exception: environment groups cannot reference services, so MinIO owns the generatedMINIO_ROOT_PASSWORDand the app services mirror it intoS3_SECRET_ACCESS_KEYwith afromServicereference. - URL variables are composed at startup. Render Blueprints can wire a
service's hostname into an environment variable but cannot build the
https://<hostname>string, so each app service receives host-only references (ROOMOTE_WEB_HOST,ROOMOTE_API_HOST,ROOMOTE_MINIO_HOST,ROOMOTE_MINIO_HOSTPORT) and the image's entrypoint dispatcher derives the URL-shaped variables (R_APP_URL,TRPC_URL,S3_ENDPOINT,S3_PRESIGN_ENDPOINT) from them when they are unset. - The setup wizard is token-gated by default.
onrender.comdomains are publicly reachable, so the Blueprint generatesSETUP_TOKEN. Copy it from theroomote-sharedenvironment group in the Render dashboard to open/setup. - Provider credentials live in the app, not the Blueprint. The Blueprint
ships with zero 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. - Live previews are off by default. Preview subdomains need a wildcard
domain, and
onrender.comdomains are single-label. Previews can be enabled later with a wildcard custom domain (see below); everything else works without them.
- A Render account. The Blueprint uses paid instance types (Render's free tier has no background workers, persistent disks, or pre-deploy commands).
- 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).
Click the Deploy to Render button at the top of this guide (or create a
new Blueprint in the Render dashboard and point it at this repository).
Render reads render.yaml, shows the services it will
create, and provisions everything on approve: Postgres, Key Value (Redis),
MinIO with a persistent disk, the api and web services, and the controller
and bullmq background workers.
After the first deploy, verify the hostname wiring before onboarding: the
Blueprint composes its URLs from cross-service RENDER_EXTERNAL_HOSTNAME
references, so open one app service's Environment tab and confirm
ROOMOTE_WEB_HOST, ROOMOTE_API_HOST, and ROOMOTE_MINIO_HOST resolved to
real hostnames (see
Maintaining the template). If they are empty,
set the three values manually to the services' onrender.com hostnames and
redeploy.
A service can also lose this race only on its very first boot: its
container snapshots the environment before the referenced hostname has
propagated, and the service crash-loops logging
❌ Invalid environment variables with a composed URL reported as
Required — even though the Environment tab already shows the resolved
hostname. The stored value is fine; the running container just predates
it. The entrypoint dispatcher heals the self-referencing case on its own
(a service's own hostname falls back to the Render-injected
RENDER_EXTERNAL_HOSTNAME), so only a cross-service reference can still
hit this. Fix it with Manual Deploy → Deploy latest reference on the
affected service to take a fresh environment snapshot.
After the first deploy, also consider disabling Auto-Sync on the
Blueprint (Blueprint settings in the Render dashboard). With Auto-Sync on,
Render re-applies render.yaml whenever the tracked branch moves, which
keeps your deployment aligned with upstream template changes but can also
apply changes you have not reviewed.
Both published images (ghcr.io/roocodeinc/roomote-app and
ghcr.io/roocodeinc/roomote-worker) are public. Render 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 are needed. MinIO comes from Docker Hub.
The Blueprint tracks the mutable :main alias (stable main-branch
builds), which the publish workflow moves on every build of main
(releases also move :latest). This matches the stable channel used by
the Railway deploy button. Nothing else in the Blueprint 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.
Render does not redeploy when a mutable alias moves. To pick up a new
main build, redeploy the four app services from the dashboard (Manual
Deploy → Deploy latest reference). To pin instead (recommended for
production deployments): put the same immutable tag (v* or
main-<sha>) in the four app-service image.url fields. No other edits
are needed — the derived values follow the image.
MinIO is the exception: the Blueprint pins it to an immutable RELEASE.*
tag and manifest digest because the service owns the artifact disk, and a
mutable latest tag could pull an unreviewed breaking change on redeploy. Bump the pin
deliberately, ideally right after a disk backup.
| Render service | Type | Source | Public domain | Healthcheck |
|---|---|---|---|---|
roomote-postgres |
Postgres | Render managed PostgreSQL 17 | no | managed |
roomote-redis |
Key Value | Render managed (Redis API) | no (empty ipAllowList) |
managed |
roomote-minio |
web | minio/minio + disk /data |
yes (routed to port 9000) | /minio/health/live |
roomote-api |
web | roomote-app:main |
yes | /health/liveness |
roomote-web |
web | roomote-app:main |
yes | /health |
roomote-controller |
worker | roomote-app:main |
no | — |
roomote-bullmq |
worker | roomote-app:main |
no | — |
Render's dockerCommand bypasses the image entrypoint and executes the
command directly, so every app service uses the full
/roomote/.docker/app/entrypoint.sh <service> form to select its process
through the image's entrypoint dispatcher. Keep these commands quote-free:
Render's dockerCommand parser passes quote characters through literally
instead of stripping them, so a /bin/sh -c '...' wrapper reaches the
shell as one quoted word and the service exits with status 127. The
dispatcher itself composes the URL variables (see
Environment variables). The api service's
pre-deploy command is /roomote/.docker/app/entrypoint.sh db-migrate,
so schema migrations run before each new deploy starts serving. On a fresh
Blueprint 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; Render's restart behavior still recovers them in the rare
case migrations take longer.
The web and api services honor Render's injected PORT. The minio service
pins PORT=9000 so Render routes its public domain to the S3 API (the
console on 9001 is not exposed).
Shared values live in the roomote-shared environment group (visible
under Environment Groups in the Render dashboard, where you can read
SETUP_TOKEN and DASHBOARD_PASSWORD after the first deploy):
R_APP_ENV=production
ROOMOTE_DOCKER_LOAD_ENV_FILE=false
R_AUTO_GENERATE_KEYS=true
ENCRYPTION_KEY=<generateValue>
ARTIFACT_SIGNING_KEY=<generateValue>
DASHBOARD_PASSWORD=<generateValue>
SETUP_TOKEN=<generateValue>
S3_ACCESS_KEY_ID=roomote
S3_REGION=us-east-1
S3_BUCKET_ARTIFACTS=roomote-artifacts
S3_AUTO_CREATE_BUCKET=true
DEFAULT_COMPUTE_PROVIDER=modal
EXCLUDED_COMPUTE_PROVIDERS=docker
DOCKER_WORKER_RELEASE_PATH=/roomote/releases/worker-current.tar.gzEach app service additionally references:
DATABASE_URL=<fromDatabase roomote-postgres>
REDIS_URL=<fromService roomote-redis>
S3_SECRET_ACCESS_KEY=<fromService roomote-minio MINIO_ROOT_PASSWORD>
ROOMOTE_WEB_HOST=<fromService roomote-web RENDER_EXTERNAL_HOSTNAME>
ROOMOTE_API_HOST=<fromService roomote-api RENDER_EXTERNAL_HOSTNAME>
ROOMOTE_MINIO_HOST=<fromService roomote-minio RENDER_EXTERNAL_HOSTNAME>
ROOMOTE_MINIO_HOSTPORT=<fromService roomote-minio host:port>and the image's entrypoint dispatcher composes the URL-shaped values from those host references at startup (explicitly set values always win):
R_APP_URL=https://$ROOMOTE_WEB_HOST
TRPC_URL=https://$ROOMOTE_API_HOST
S3_ENDPOINT=http://$ROOMOTE_MINIO_HOSTPORT
S3_PRESIGN_ENDPOINT=https://$ROOMOTE_MINIO_HOSTThe roomote-shared group also sets
DOCKER_WORKER_RELEASE_PATH=/roomote/releases/worker-current.tar.gz, which
only the controller reads. The 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 remove it: without it the controller falls back
to fetching GitHub worker releases, which do not exist for develop builds.
Notes:
R_APP_URLis composed from the web service's hostname — it is the origin users browse. 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. MODAL_TOKEN_ID,MODAL_TOKEN_SECRET, and model provider keys such asOPENROUTER_API_KEYare not Blueprint 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.- 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. - Leave
PREVIEW_PROXY_BASE_URLandPREVIEW_DOMAINSunset unless you enable live previews. Roomote boots without them; previews report as not configured in Settings → Live Previews until set.
MinIO does not auto-create buckets, so the Blueprint sets
S3_AUTO_CREATE_BUCKET=true: 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 the api service's logs.
Presigned artifact URLs use the minio service's public domain
(S3_PRESIGN_ENDPOINT), so hosted-sandbox workers and browsers can download
artifacts directly. 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
roomote-webandroomote-apito report live. On a brand-new Blueprint 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). - Copy
SETUP_TOKENfrom theroomote-sharedenvironment group and openhttps://<web-domain>/setup?token=<SETUP_TOKEN>(or paste the token into the wizard's token step). - 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).
The Blueprint boots on onrender.com domains, and R_APP_URL — the
origin users browse — is composed from the web service's
RENDER_EXTERNAL_HOSTNAME. Render does not change that variable when
you attach a custom domain (it is always the service's onrender.com
hostname), 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 explicit fix:
- Add the custom domain (for example
app.example.com) to the roomote-web service in Render and complete the DNS setup. - On each app service (api, web, controller, bullmq), override
ROOMOTE_WEB_HOST=app.example.comin the service's Environment tab (replacing the Blueprint-managed reference) and let the services redeploy.
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 ROOMOTE_API_HOST 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 through ROOMOTE_API_HOST and ROOMOTE_MINIO_HOST.)
Live previews need a wildcard domain, which requires a domain you control:
- Add a
roomote-preview-proxyweb service to the Blueprint (or from the dashboard) using the same image, the same environment references as the other app services, thedockerCommand/roomote/.docker/app/entrypoint.sh preview-proxy, and healthcheck path/health. - Point
previews.<your-domain>and*.previews.<your-domain>at Render as custom domains on that service, following Render's wildcard custom domain docs. - Add
PREVIEW_PROXY_BASE_URL=https://previews.<your-domain>,NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL=https://previews.<your-domain>, andPREVIEW_DOMAINS=previews.<your-domain>to theroomote-sharedenvironment group. TheNEXT_PUBLIC_variant is what the web client uses to build preview links, so theroomote-webservice must have it. - Opt in from Settings → Live Previews, which validates the wildcard hostname and enables previews per deployment and environment.
- Upgrade a deployment on
:mainby redeploying the four 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 fourimage.urlfields first (via Blueprint sync or the dashboard). 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. - Back up the Render Postgres database (Render's built-in backups or
pg_dump) and the MinIO disk or external bucket. Everything else is reproducible from the Blueprint plus the generated environment group values. - Costs split three ways: Render hosts the control plane (web, api, controller, bullmq, Postgres, Key Value, MinIO — all on paid instance types), while task execution bills through your sandbox provider (Modal/E2B/Daytona) and model usage bills through your model provider. Upgrade individual instance types from the dashboard as usage grows.
When the Blueprint needs to change, edit render.yaml
at the repository root and re-run the first-boot verification on a scratch
Render workspace before merging. The verification must confirm that the
cross-service RENDER_EXTERNAL_HOSTNAME references resolve — open each app
service's Environment tab and check that ROOMOTE_WEB_HOST,
ROOMOTE_API_HOST, and ROOMOTE_MINIO_HOST are real hostnames — and that
the composed URLs reach the running services (/health on web,
/health/liveness on api). Keep the file at the repository root: the
Deploy to Render button and Blueprint sync only read render.yaml from
there.