A preview environment is a temporary copy of your services and databases, spun up automatically (or on demand) when a pull request opens. Every reviewer gets a real URL with the PR’s code running on it, and the whole thing tears itself down after a few days.
Done right, it’s the most useful feature of render.yaml. Done carelessly, it costs more than your production environment.
The two top-level knobs
previews: generation: automatic expireAfterDays: 3That’s the whole top-level surface. Two fields, big behavioral differences.
previews.generation
| Value | Behavior |
|---|---|
off (default) | No previews. The Blueprint behaves as if previews: weren’t there. |
manual | Previews don’t auto-create. A teammate clicks “Create preview” in the Render Dashboard for the PRs they want to spin up. |
automatic | Every eligible PR gets a preview. Closing or merging the PR tears it down. |
flowchart LR pr["Pull request opened"] off["off → ignored"] manual["manual → wait for click"] auto["automatic → preview spins up"] pr --> off pr --> manual pr --> auto
automatic is what most teams want once they’ve trusted previews for a few PRs. Start with manual while you’re tuning the Blueprint, then graduate.
previews.expireAfterDays
A positive integer auto-deletes the preview stack N days after creation, regardless of PR state. Without expiry, a long-lived PR (or a closed PR whose teardown silently failed) keeps charging your account until you notice.
A safe default for most teams: expireAfterDays: 3. That covers a typical PR review cycle and forces a re-create on stale work, which is usually what you want anyway.
Per-service overrides
Sometimes you want previews - but not for every service. A worker that posts to your real billing webhook? Skip it. A cron that emails customers on a schedule? Definitely skip it.
previews: generation: automatic
services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start
- type: worker name: billing-webhook runtime: node plan: starter buildCommand: npm ci startCommand: node webhook.js previews: generation: offThe web service inherits automatic. The worker opts out at the service level. Same syntax for cron jobs and private services.
Smaller plans for previews
Production runs on standard. Previews don’t need that - they get one reviewer’s traffic, not your customers’. previewPlan lets each service downsize for previews:
services: - type: web name: api runtime: node plan: standard previewPlan: starter buildCommand: npm ci && npm run build startCommand: npm startThe same field works on databases via previewPlan and previewDiskSizeGB:
databases: - name: app-db plan: basic-1gb previewPlan: basic-256mb previewDiskSizeGB: 5The sync: false gotcha that breaks most preview deploys
This is the single most-asked question in #help-render:
“My preview deployed but the app crashes with
STRIPE_API_KEY is undefined. Production works fine.”
Cause: sync: false env vars are not propagated to preview environments. Each preview spins up empty. The app boots, doesn’t find the key, and crashes.
flowchart LR prod["Production service<br/>STRIPE_API_KEY=sk_live_..."] pr["Pull request"] preview["Preview service<br/>STRIPE_API_KEY=undefined"] prod -.->|"sync: false<br/>doesn't copy"| preview pr --> preview preview --> crash["Boot failure"]
Three ways out, in increasing order of effort:
-
Use a sandbox key for previews. Move the var out of
sync: falseand into avalue:that points at your provider’s test key. Stripe, OpenAI, Anthropic, Twilio - all have sandbox credentials safe to commit. -
Pull the secret from a workspace env group. Workspace-scoped env groups are available in previews. Move the secret into a Render Dashboard-managed group, attach it to the service via
fromGroup, and previews inherit it withoutsync: false. -
Inject from CI. Your PR pipeline writes the secret into the preview after creation via the Render API. More moving parts, but it’s the only option when the secret is per-PR (e.g. test data isolated to that PR).
Option 2 is the right answer for most teams. The rule: anything sensitive enough for sync: false is sensitive enough for an env group.
Autoscaling and previews
flowchart LR prod["Production: scaling<br/>min 2, max 10"] preview["Preview: pinned to min 2"] prod -.->|"autoscaling disabled<br/>in previews"| preview
The implication is mostly cost. A heavy-instance autoscaling service that serves a lot of traffic in production might run a single preview instance for $0/PR (with a small previewPlan) or hundreds of dollars per month at the production minimum. Set previewPlan aggressively.
A complete preview-aware Blueprint
previews: generation: automatic expireAfterDays: 3
databases: - name: app-db plan: basic-1gb previewPlan: basic-256mb previewDiskSizeGB: 5
services: - type: web name: api runtime: node plan: standard previewPlan: starter buildCommand: npm ci && npm run build startCommand: npm start envVars: - key: DATABASE_URL fromDatabase: name: app-db property: connectionString - key: STRIPE_API_KEY fromGroup: shared-secrets
- type: worker name: billing-webhook runtime: node plan: starter buildCommand: npm ci startCommand: node webhook.js previews: generation: off
envVarGroups: - name: shared-secrets envVars: - key: STRIPE_API_KEYWalk it: previews on for the project, off for the billing worker; smaller plans for both web and DB; secrets via a workspace group instead of sync: false. That’s the preview-friendly pattern.
What you learned
- `previews.generation`: `off` (default), `manual`, `automatic`. Set `expireAfterDays` to cap cost
- Per-service `previews.generation: off` opts out workers, crons, or private services that shouldn't run in previews
- `previewPlan` (and `previewDiskSizeGB` on databases) downsizes preview instances; can't cross flexible / non-flexible families
- `sync: false` is excluded from previews - switch the var to a workspace env group with `fromGroup`, or use sandbox keys
- Autoscaling is disabled in previews - services run at their minimum instance count