The default Blueprint pattern is a flat list of services: and databases: at the root. It’s perfect for a single app - until you want a staging copy, or a second app that should live in the same Render project, or env groups scoped to one of those environments.
That’s when you reach for projects + environments.
The two shapes, side by side
flowchart LR
subgraph flat [Flat layout]
direction TB
rs1[services]
rd1[databases]
rg1[envVarGroups]
end
subgraph nested [Projects + environments]
direction TB
p[project: my-app]
e1[environment: production]
e2[environment: staging]
rs2[services]
rd2[databases]
p --> e1
p --> e2
e1 --> rs2
e1 --> rd2
end
Both shapes are valid YAML and both get a green light from render blueprints validate. The difference is what Render does with the resources after a sync.
Flat: one project, no environment story
databases: - name: app-db plan: basic-256mb
services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionStringThe web service and database land in a Render project named after your repo. There’s no concept of a second environment - if you want staging, you have to fork the file or duplicate every resource with a -staging suffix.
Nested: one project, many environments
projects: - name: my-app environments: - name: production databases: - name: app-db plan: basic-256mb services: - type: web name: api runtime: node plan: standard buildCommand: npm ci && npm run build startCommand: npm start envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionString
- name: staging databases: - name: app-db plan: basic-256mb services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionStringSame names (api, app-db) appear in both environments - that’s intentional. fromDatabase and fromService resolve within the same environment, so production’s api wires to production’s app-db and staging’s api wires to staging’s app-db. No collisions, no duplicated bookkeeping.
When to migrate
Reach for the projects pattern when at least one of these is true:
- You have two or more services that belong to the same logical app.
- You want staging and production in the same Blueprint.
- You want env groups scoped to a single environment (e.g. a staging-only
LLM_API_KEY). - You want environment isolation so private network traffic can’t cross between staging and production. Available on Professional+ plans.
If your app is a single web service with no staging, the flat layout is fine. Don’t migrate for the sake of migrating.
Anatomy of an environment
Each environment block is a self-contained mini-Blueprint:
| Field | What it holds |
|---|---|
name | The environment key. Convention: production, staging, preview-base. |
services | Same schema as top-level services. Web, worker, cron, pserv, keyvalue. |
databases | Same schema as top-level databases. |
envVarGroups | Env groups scoped to this environment. Production secrets stay out of staging. |
envVarGroups can also live one level up - under the project - when you want a group shared across every environment in the project.
projects: - name: my-app envVarGroups: - name: shared-config envVars: - key: LOG_LEVEL value: info environments: - name: production envVarGroups: - name: production-secrets envVars: - key: STRIPE_API_KEYWe dig into env group strategy in the next step. For now, the takeaway is that scoping is a real lever: workspace > project > environment.
The big footgun: don’t define a resource twice
flowchart LR topServices[root-level services] envServices[environment services] topServices -.->|"same name"| envServices envServices -.-> conflict["Reconcile churn or<br/>name collisions"]
The fix when you spot this in a real file is mechanical: keep the version inside the environment, delete the root-level copy, run render blueprints validate, then sync.
A worked migration
You already have a flat Blueprint in production. Here’s the diff to add a staging environment:
databases: - name: app-db plan: basic-256mb
services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionStringprojects: - name: my-app environments: - name: production databases: - name: app-db plan: basic-256mb services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionString - name: staging databases: - name: app-db plan: basic-256mb services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionStringRender reconciles the existing production resources into the new layout - they don’t get destroyed and recreated, as long as the environment name (production) and resource names (api, app-db) match what’s already in the workspace. Staging spins up fresh.
What you learned
- Flat services/databases is fine for single-service apps; reach for projects + environments when you need staging or env-scoped configuration
- Each environment is a self-contained mini-Blueprint with its own services, databases, and optional env groups
- `fromDatabase` and `fromService` resolve within the same environment - same names across environments don't collide
- Never define the same logical resource at both the root and inside an environment