render_ops is an infrastructure-only repo. It holds one Render Blueprint (render.yaml) per
application, and contains no application code. Each application (Flask URL shortener, Spring Boot
URL shortener, code runner, and so on) lives in its own GitHub repo with its own tests and its
own CI. This guide explains how the pieces connect, how a deploy actually happens, and how to add
a new API.
flowchart LR
subgraph OPS["render_ops repo (infra only)"]
B1["url-shortener-flask/render.yaml"]
B2["url-shortener-springboot/render.yaml"]
B3["code-runner/render.yaml"]
V["validate-blueprints.yml"]
end
subgraph APPS["Application repos (code + CI)"]
A1["url-shortener-flask"]
A2["url-shortener-springboot"]
A3["code-runner"]
end
subgraph RENDER["Render"]
BP["Blueprints, one per render.yaml path"]
S1["Web service: url-shortener-flask"]
S2["Web service: url-shortener-springboot"]
S3["Web service: code-runner"]
end
B1 --> BP
B2 --> BP
B3 --> BP
BP --> S1
BP --> S2
BP --> S3
A1 -. "repo field: code is pulled from here" .-> S1
A2 -. "repo field" .-> S2
A3 -. "repo field" .-> S3
A1 -- "CI passes, then deploy hook" --> S1
A2 -- "CI passes, then deploy hook" --> S2
A3 -- "CI passes, then deploy hook" --> S3
V -. "validates every render.yaml on PR" .-> OPS
Two separate things are kept apart on purpose:
| Concern | Lives in | Changes when you... |
|---|---|---|
| What infrastructure exists (plan, region, env vars, start command) | render_ops |
change a setting, add a service, upgrade a plan |
| What code runs on it | each app's own repo | change application logic |
render_ops/
├── url-shortener-flask/
│ └── render.yaml
├── url-shortener-springboot/
│ └── render.yaml
├── code-runner/
│ └── render.yaml
├── .github/
│ └── workflows/
│ └── validate-blueprints.yml
└── RENDER_OPS_GUIDE.md # this file
One folder per application, one render.yaml per folder. Each one becomes its own Blueprint in
Render, so a bad edit to one API's config can only re-sync that one API.
- Blueprints accept a custom file path.
render.yamldoesn't have to sit at the repo root. When you create a Blueprint in the Dashboard you point it aturl-shortener-flask/render.yaml(or any other path) insiderender_ops. - Every service has a
repofield. It defaults to the repo that contains therender.yaml, but you can set it to any repo. That is what lets a Blueprint stored inrender_opsbuild code that lives in a completely different repository.
Python service (url-shortener-flask/render.yaml):
services:
- type: web
name: url-shortener-flask
runtime: python
repo: https://github.com/<your-org>/url-shortener-flask # code lives here
branch: main
plan: free
region: oregon
buildCommand: "pip install -r requirements.txt"
startCommand: "gunicorn app:app --bind 0.0.0.0:$PORT"
healthCheckPath: /
autoDeploy: false # deploys come from the app repo's GitHub Action
envVars:
- key: PYTHON_VERSION
value: 3.11.9Docker service (url-shortener-springboot/render.yaml):
services:
- type: web
name: url-shortener-springboot
runtime: docker
repo: https://github.com/<your-org>/url-shortener-springboot
branch: main
plan: free
region: oregon
dockerfilePath: ./Dockerfile
dockerContext: .
healthCheckPath: /
autoDeploy: falseKey fields:
| Field | Purpose |
|---|---|
repo / branch |
Where Render pulls application code from, not render_ops |
runtime |
python, node, or docker (Java goes through docker) |
plan |
free for demos; paid plans for always-on and scaling |
healthCheckPath |
Render waits for this to return success before routing traffic |
autoDeploy: false |
Render does not deploy on every push, so CI can gate deploys |
sequenceDiagram
autonumber
actor Dev as Developer
participant OPS as render_ops repo
participant CIo as render_ops CI
participant APP as App repo
participant CIa as App repo CI
participant R as Render
participant SVC as Running service
rect rgb(235, 245, 255)
Note over Dev,R: Loop B: infrastructure change
Dev->>OPS: Open PR editing a render.yaml
OPS->>CIo: validate-blueprints.yml
CIo->>CIo: render blueprints validate, per file
CIo-->>OPS: Pass or fail
Dev->>OPS: Merge to main
OPS-->>R: Webhook, Blueprint sync if Auto-Sync is on
R->>R: Create or update only the service in that Blueprint
end
rect rgb(240, 255, 240)
Note over Dev,SVC: Loop A: application code change
Dev->>APP: Push to main
APP->>CIa: deploy.yml
CIa->>CIa: Install, smoke test or build and test
CIa->>R: POST to the Deploy Hook (only if tests passed)
R->>APP: Clone repo at branch from Blueprint
R->>R: Build (buildCommand or Dockerfile)
R->>SVC: Start and wait for healthCheckPath
R-->>SVC: Route traffic to the new version
end
- Push to the app repo's
main. - Its
.github/workflows/deploy.ymlinstalls dependencies and runs tests (a syntax or import smoke test for the Python and Node apps,mvn packagefor Spring Boot). - Only if that job passes, a second job
curls the service's Deploy Hook URL, stored as the repo secretRENDER_DEPLOY_HOOK_URL. - Render clones the repo at the branch named in the Blueprint, builds, health-checks, and switches traffic over.
A failing test stops the pipeline before the deploy hook is called, so broken code never ships.
That is the reason for autoDeploy: false: with auto-deploy on, Render would deploy on push
regardless of whether CI passed.
- Edit a
render.yaml(new env var, plan upgrade, new service) and open a PR. validate-blueprints.ymlrunsrender blueprints validateon everyrender.yamlin the repo, catching schema typos, bad enum values, and broken references before merge.- Merge to
main. Render applies the change to that one Blueprint's service. This relies on the Blueprint's Auto-Sync setting being enabled in the Dashboard; if you turned it off, trigger a manual sync from the Blueprint's page instead.
.github/workflows/validate-blueprints.yml:
name: Validate Render Blueprints
on:
pull_request:
paths:
- "**/render.yaml"
- ".github/workflows/validate-blueprints.yml"
push:
branches: [main]
paths:
- "**/render.yaml"
jobs:
validate:
name: Validate each render.yaml
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install the Render CLI
run: |
curl -fsSL https://raw.githubusercontent.com/render-oss/cli/main/bin/install.sh | sh
echo "$HOME/.render/bin" >> "$GITHUB_PATH"
- name: Validate every render.yaml in the repo
run: |
status=0
for f in $(find . -name "render.yaml" -not -path "./node_modules/*"); do
echo "== Validating $f =="
render blueprints validate "$f" || status=1
done
exit $statusIt only triggers when a render.yaml changes, loops over every one, and fails the check if any
single file is invalid. You can run the same check locally:
render blueprints validate url-shortener-flask/render.yamlIf the installer URL or command changes in a future CLI release, check Render's CLI docs. The check itself is a static schema validation and doesn't modify any live resources.
Say you are adding chat-video:
-
Create the app repo (
chat-video) with its code and its own.github/workflows/deploy.yml(test job, then a deploy-hook job). Do not put arender.yamlin it. -
Add a folder to
render_ops:chat-video/render.yaml, withrepo:pointing at the new app repo andautoDeploy: false. -
Open a PR to
render_opsand let the validation check pass, then merge. -
Create the Blueprint in Render (one-time, in the Dashboard): New → Blueprint → connect
render_ops→ set the Blueprint file path tochat-video/render.yaml→ Apply. -
Grant GitHub access: Render's GitHub App must be able to read both
render_ops(for the Blueprint) and the new app repo (for the coderepo:points at). -
Wire up the deploy hook: copy the new service's Deploy Hook (Settings → Deploy Hook) into the app repo as a secret:
gh secret set RENDER_DEPLOY_HOOK_URL --repo <your-org>/chat-video --body '<hook-url>'
-
Push to the app repo's
main. Loop A now takes over for every future code change.
Step 4 is the only manual step per API. After that, both loops run on their own.
If a service later needs Redis-compatible storage, add a keyvalue entry to that API's
render.yaml. On the free plan it is in-memory only (a restart clears it) and limited to one
instance per workspace:
- type: keyvalue
name: url-shortener-cache
plan: free
ipAllowList: [] # private to your Render servicesFree Postgres exists too, but free databases expire after a limited period (30 days at the time of writing), so treat them as temporary.
| Symptom | Likely cause |
|---|---|
| Blueprint creation says the repo can't be found or built | Render's GitHub App lacks access to the app repo named in repo: |
| Code pushed but nothing deployed | RENDER_DEPLOY_HOOK_URL secret missing or wrong, or the test job failed first |
| Deployed on every push, ignoring CI | autoDeploy is true in that render.yaml |
render.yaml change merged but service unchanged |
Auto-Sync is off for that Blueprint, so sync manually from its page |
| Validation job fails on a PR | Run render blueprints validate <file> locally to see the schema error |
| First request is slow on the free plan | Free services spin down after about 15 minutes idle and cold-start on the next request |
render_ops= declarations of infrastructure, onerender.yamlper app, validated on every PR.- App repos = code, tests, and a deploy-hook workflow, with no infrastructure files.
- Render Blueprint = the bridge: it lives at a path inside
render_opsbut itsrepo:field points at the app repo. - Two independent loops: infra changes flow through validation then Blueprint sync, and code changes flow through CI then a deploy hook.