# Render

> Use when deploying or fixing an app on Render — web services, background workers, cron jobs, private services, managed Postgres and Key-Value, and especially the render.yaml Blueprint. Covers deploys that fail with no open ports detected, 502s on first deploy, free-tier cold starts, and a free Postgres about to expire with its data. NOT the generic ship and release flow (that is `deployment`), NOT another PaaS such as `railway` or `fly-io`.

- Skill: `ericrisco/render` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add ericrisco/render`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/render/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/render

---


# Render — make this repo deploy correctly, wired as code

Take any repo and make it deploy on **Render** (render.com) in one pass: pick the right
service type, declare everything in a version-controlled `render.yaml` Blueprint, bind the
port the way Render expects, and wire `DATABASE_URL` / secrets across services so they
survive rotation. Steer clear of the four traps that eat first deploys: the
"no open ports detected" 502, the free-tier spin-down, the 30-day free-Postgres expiry,
and the 12-hour cron kill.

```text
pick service type → render.yaml at repo root → bind 0.0.0.0:$PORT → wire env across services → push (auto-deploy)
        │                                                                                            │
   web|worker|cron|pserv|static|keyvalue                                       full key surface → references/blueprint-reference.md
```

Facts here are dated to **2026-06-02** against render.com/docs. Render ships changes; if a
key or limit looks off, confirm against the live Blueprint spec before betting a deploy on it.

## The one decision that decides everything: service type

Pick the type **before** you write a line of YAML. The type sets the billing model, whether
the process gets a public URL, and whether Render expects it to bind a port. Choosing wrong
means a worker that never starts because Render waited for a port, or a 12-hour job that dies
silently as a cron.

| The process… | Type | `runtime` required? | Gets a public URL? |
| --- | --- | --- | --- |
| serves HTTP/WebSocket traffic | `web` | yes | yes |
| runs forever, no inbound URL (queue consumer, Celery) | `worker` | yes | no |
| runs, does work, exits — on a schedule | `cron` | yes | no |
| internal-only API, reachable only inside Render's network | `pserv` | yes | no (internal host only) |
| pre-built static assets (SPA, docs) | `web` + `runtime: static` | yes (`static`) | yes |
| cache / queue / Redis-compatible store | `keyvalue` | **no** | no (internal) |

`runtime` enum (everything except `keyvalue`): `node`, `python`, `docker`, `image`,
`static`, `go`, `ruby`, `elixir`, `rust`.

## Blueprint-first: everything lives in render.yaml

**Rule: declare every service, database, and env var group in `render.yaml` at the repo
root. Touch the dashboard only for `sync: false` secrets and one-off debugging.** Why: the
Blueprint is the reproducible, reviewable source of truth — it powers preview environments
and a clean re-deploy, while dashboard-only config is invisible state that drifts and can't
be code-reviewed.

Top-level keys: `services`, `databases`, `envVarGroups`, `projects`, `previews`.

## Minimal correct render.yaml (annotated)

A multi-service app — a Node web service, a Python worker, a nightly cron, a Postgres db,
and a shared env group. Every load-bearing key is commented.

```yaml
databases:
  - name: app-db
    plan: starter            # NOT free for anything you care about — free expires in 30 days
    postgresMajorVersion: "17"
    region: frankfurt

envVarGroups:
  - name: app-shared
    envVars:
      - key: LOG_LEVEL
        value: info
      - key: SENTRY_DSN
        sync: false          # prompt once at setup; never stored in git

services:
  - type: web
    name: api
    runtime: node
    region: frankfurt
    plan: starter            # $7/mo — avoids the free-tier 15-min spin-down on an API
    buildCommand: npm ci && npm run build
    startCommand: npm start  # MUST bind 0.0.0.0 and read $PORT — see next section
    healthCheckPath: /healthz # gates zero-downtime rollout; new instance must pass first
    preDeployCommand: npm run migrate  # runs before the new instance serves traffic
    autoDeployTrigger: commit          # commit | checksPass | off
    envVars:
      - fromGroup: app-shared
      - key: DATABASE_URL
        fromDatabase:
          name: app-db
          property: connectionString   # never hardcode the URL
      - key: SESSION_SECRET
        generateValue: true            # Render generates a random secret

  - type: worker
    name: jobs
    runtime: python
    plan: starter
    buildCommand: pip install -r requirements.txt
    startCommand: celery -A app worker -l info  # no port — workers don't bind one
    envVars:
      - fromGroup: app-shared
      - key: DATABASE_URL
        fromDatabase: { name: app-db, property: connectionString }
      - key: API_URL
        fromService:                   # reference another service
          name: api
          type: web
          property: hostport

  - type: cron
    name: nightly-cleanup
    runtime: python
    schedule: "0 3 * * *"              # required for cron; 03:00 UTC daily
    buildCommand: pip install -r requirements.txt
    startCommand: python -m app.cleanup
    envVars:
      - key: DATABASE_URL
        fromDatabase: { name: app-db, property: connectionString }
```

## Port binding — do this first (the #1 first-deploy failure)

**Rule: a `web` service MUST listen on host `0.0.0.0` and read the `PORT` env var (Render
sets it, default `10000`).** If Render detects no bound port within its window, the deploy
**fails with "no open ports detected"** and visitors get a **502**. This is the single most
common first-deploy break — binding `localhost`/`127.0.0.1` or a hardcoded port does it.

```javascript
// Bad — binds the wrong host and ignores Render's PORT → "no open ports detected"
app.listen(3000);

// Good — bind 0.0.0.0 and honor $PORT
app.listen(process.env.PORT || 3000, "0.0.0.0");
```

```bash
# Bad — gunicorn on a fixed local port
gunicorn app:app --bind 127.0.0.1:8000

# Good — bind 0.0.0.0 and Render's $PORT
gunicorn app:app --bind 0.0.0.0:$PORT
```

Only `web` services need this. A `worker`/`cron` that tries to bind a port is fine but
pointless; a `web` service that doesn't is broken.

## Wiring env vars across services

**Rule: never hardcode `DATABASE_URL`, `REDIS_URL`, or shared secrets as literal `value:`
strings. Reference the resource** so the value survives a rotation, recreate, or region
move, and so secrets never land in git.

| Form | Use it for |
| --- | --- |
| `fromDatabase: { name, property: connectionString }` | the Postgres connection string |
| `fromService: { name, type, property }` | another service's host/port/URL (`property: hostport`, `host`, or `port`) |
| `fromGroup: <group-name>` | pull a whole shared env var group |
| `generateValue: true` | a random secret Render generates and stores (session keys) |
| `sync: false` | a secret you type once at setup; not stored in the repo |

For a Key Value store, reference its connection string the same way you reference Postgres,
via `fromService` against the `keyvalue` service.

## Migrations and zero-downtime deploys

- `preDeployCommand` — runs **once**, before the new instance starts serving traffic. Put
  migrations here, not in `startCommand` (a startCommand migration runs on every instance
  and races under multiple replicas).
- `healthCheckPath` — Render polls it on the new instance and only shifts traffic once it
  passes, giving zero-downtime rollout. Point it at a route that checks real readiness.
- `autoDeployTrigger` — `commit` (deploy every push), `checksPass` (wait for CI status),
  or `off` (manual / deploy-hook only).

## Free-tier traps

Render's free tier is generous for hobby work and a landmine for anything you care about.

| Trap | What happens | Fix |
| --- | --- | --- |
| Free web spin-down | after **15 min** of no inbound traffic the instance sleeps; next request waits **30–60s** to wake | Starter at **$7/mo** per service |
| 750 free instance-hrs/mo | shared across the workspace; spun-down time doesn't count toward it | budget it, or pay Starter |
| Free Postgres expiry | **deleted 30 days after creation** (14-day grace to upgrade), all data gone; only **one** free Postgres per workspace | Starter Postgres ($7/mo) from day one for anything real |
| Free Key Value | no disk persistence — data is lost on restart | paid plan if you need durability |

## Cron specifics

- `schedule:` (standard cron expression) is **required** for `type: cron`.
- Render guarantees **at most one active run at a time** — runs don't overlap.
- **A run is killed after 12 hours.** Anything that can exceed that must be a `worker` with
  its own scheduler/queue, not a cron. A long cron fails silently mid-job — partial work,
  no clean error.

## Scaling and disk knobs

This is the concrete Render surface, not capacity strategy (that's `scaling`).

```yaml
services:
  - type: web
    name: api
    runtime: node
    scaling:
      minInstances: 1
      maxInstances: 4
      targetCPUPercent: 70
      targetMemoryPercent: 80   # autoscale between min/max on CPU/mem
    disk:
      name: data
      mountPath: /var/data
      sizeGB: 10                # a disk PINS the service to ONE instance — blocks horizontal scale
```

Use `numInstances` for a fixed replica count instead of `scaling` when you don't want
autoscaling. A persistent `disk` and horizontal scaling are mutually exclusive — pick one.

Regions: `oregon`, `ohio`, `virginia`, `frankfurt`, `singapore`. Plans: `free`, `starter`,
`standard`, `pro`, `pro plus`, `pro max`, `pro ultra`.

## Anti-patterns

| Anti-pattern | Why it bites | Do instead |
| --- | --- | --- |
| Hardcoding a port (`listen(3000)`) | "no open ports detected" → deploy fails / 502 | bind `0.0.0.0` and `$PORT` |
| Hardcoding `DATABASE_URL` | breaks on rotation / db recreate | `fromDatabase` reference |
| A long job as a `cron` | killed at 12h, silent partial work | `worker` + its own scheduler/queue |
| Free Postgres for production | deleted 30 days after creation | Starter ($7/mo) from day one |
| Secrets as literal `value:` in render.yaml | leaked in git history | `sync: false` or `generateValue: true` |
| Migrations in `startCommand` | runs on every instance, races under replicas | `preDeployCommand` |
| Free web service for a real API | cold-start 502s after idle | Starter, or accept it only for a hobby toy |
| Dashboard-only config | invisible drift, no preview envs, no review | declare in `render.yaml` |

## When to hand off

- Schema design, queries, indexing, tuning → `../postgresdb/SKILL.md`. This skill only
  *provisions and connects* Render's managed Postgres.
- Writing the `Dockerfile` Render consumes via `runtime: docker` → `docker`.
- Registrar-side DNS records for a custom domain → `domains-dns`. (The Render `domains:`
  block and verification stay here.)
- Cross-platform release strategy, promotion, rollback flow → `../deployment/SKILL.md` and
  `ship`. This skill is Render config and platform mechanics.
- Build steps Render's native build doesn't run (custom CI) → `github-actions`. Render
  auto-deploys on push; only reach for Actions when you genuinely need it.
- Capacity/load strategy beyond the `scaling:` knobs → `scaling`.
- A different PaaS → its own sibling: `../fly-io/SKILL.md`, `railway`, `vercel`,
  `../netlify/SKILL.md`, `coolify`, `digitalocean`.

## Full key surface

The exhaustive `render.yaml` key tables per service type, all database keys
(`postgresMajorVersion`, `diskSizeGB`, `readReplicas`, `highAvailability`, `previewPlan`),
every env-var reference form, region/plan enums, and four complete copy-paste recipes
(Next.js web+pg; FastAPI web+worker+cron+keyvalue; Docker monorepo with `rootDir`; static
SPA with `routes` rewrites) live in `references/blueprint-reference.md` — pull it open when
you need a key this body didn't cover.

