Native runtimes (node, python, go, …) cover most apps. When you need something they don’t - a custom system dep, a binary that’s a pain to install on every build, a single image you ship to several clouds - you reach for one of two container-shaped runtimes:
runtime: docker- Render builds yourDockerfilefrom the repo and runs the resulting image.runtime: image- Render runs a prebuilt image you’ve already published to a container registry.
Same end state, different operational tradeoffs.
Side-by-side: same service, two ways
services: - type: web name: api runtime: docker plan: starter dockerfilePath: ./Dockerfile dockerContext: . dockerCommand: node dist/index.js envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionStringRender clones the repo, runs docker build from the context, and deploys the resulting image. Every git push triggers a rebuild.
services: - type: web name: api runtime: image plan: starter image: url: ghcr.io/myorg/api:v1.4.2 creds: fromRegistryCreds: name: ghcr envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionStringRender pulls the published image and runs it. Pushing code does nothing - you ship a new version by publishing a new image and pointing the Blueprint at it.
The wiring (envVars, fromDatabase, disk, scaling, previews) is identical between the two - they’re both just services. What changes is where the image comes from.
runtime: docker - Render builds for you
services: - type: web name: api runtime: docker plan: starter dockerfilePath: ./apps/api/Dockerfile dockerContext: . dockerCommand: node dist/index.js envVars: - key: PORT value: "10000"| Field | What it does | Default |
|---|---|---|
dockerfilePath | Path to the Dockerfile to build, from repo root | ./Dockerfile |
dockerContext | Build context handed to docker build | The directory containing the Dockerfile |
dockerCommand | Override the container’s CMD | Whatever the Dockerfile defines |
The two you’ll override most often:
dockerContext: .- Set it to the repo root when your Dockerfile is in a subdirectory (apps/api/Dockerfile) but needs toCOPYfiles frompackages/next door. Without this override, your build fails withnot foundon thoseCOPYlines.dockerCommand- Useful when one Dockerfile produces a multi-purpose image (one for web, one for worker). Override it per service in the Blueprint instead of building two images.
services: - type: web name: api runtime: docker plan: starter dockerfilePath: ./Dockerfile dockerCommand: node dist/server.js
- type: worker name: jobs runtime: docker plan: starter dockerfilePath: ./Dockerfile dockerCommand: node dist/worker.jsRender still builds the Dockerfile twice (once per service) - but they’re identical builds and Render caches layers across them. The savings come from not maintaining two Dockerfiles.
runtime: image - bring your own image
services: - type: web name: api runtime: image plan: starter image: url: ghcr.io/myorg/api:v1.4.2 creds: fromRegistryCreds: name: ghcrThe image block has two parts:
| Field | Notes |
|---|---|
url | Fully-qualified image URL: registry/namespace/image:tag |
creds.fromRegistryCreds | Reference to a workspace-level registry credential. Omit for public images. |
Set up a registry credential in the Render Dashboard once (Account → Registry Credentials), then reference it by name. The Blueprint never holds the credential itself.
flowchart LR ci["CI builds + pushes image"] registry[(GHCR / ECR / Docker Hub)] blueprint["render.yaml - image: tag v1.4.2"] render["Render pulls + runs"] ci --> registry blueprint -->|"references tag"| registry registry --> render
A common pipeline: CI tags an image with a short Git SHA, the Blueprint pins to that tag, a separate PR bumps the tag when you want to deploy. Promotion = changing one line in render.yaml.
Picking between them
| You want | Pick |
|---|---|
| The simplest path. Push code, Render builds. | runtime: docker |
| To pin production to a specific image SHA. | runtime: image |
| To deploy the same image to multiple clouds. | runtime: image |
| Build caching across CI and Render. | runtime: image (your CI keeps the cache) |
| To not maintain a Dockerfile and a CI pipeline. | runtime: docker |
Different CMD per service from one image. | Either, but runtime: docker with dockerCommand is simpler |
The split most teams settle on: runtime: docker early, when builds are simple and CI isn’t worth the overhead. Switch to runtime: image once you have a real CI pipeline and want deploys decoupled from pushes.
The immutability rule
This is one of those things that feels like a bug until you try the alternative - silently changing a service’s runtime in place would be an uncomfortable surprise mid-deploy. The same rule applies to type (web ↔ worker ↔ cron): immutable, change requires a new service.
A small Docker performance tip
If you’re using runtime: docker, lean on layer caching. The classic package.json / package-lock.json pattern skips the npm ci step when only source code changed:
FROM node:22-alpine AS builder
WORKDIR /appCOPY package.json package-lock.json ./RUN npm ci
COPY . .RUN npm run build
FROM node:22-alpineWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/package.json ./CMD ["node", "dist/index.js"]Two-stage build, lockfile copied separately, multi-stage COPY --from=builder. With layer caching, source-only commits skip the npm ci line and rebuild in seconds instead of minutes.
What you learned
- `runtime: docker` builds your Dockerfile on Render's build host. Tune with `dockerfilePath`, `dockerContext`, and `dockerCommand`
- `runtime: image` pulls a prebuilt image from a registry. Use `image.url` plus `image.creds.fromRegistryCreds` for private registries
- Both runtimes share the same wiring, scaling, disk, and preview surfaces - they're just services with a different image source
- `runtime` is immutable. Switching runtimes requires renaming the service so Render provisions a new one and tears the old one down