A web service runs your code as a long-running process. Render’s edge proxy accepts HTTPS from the public internet, terminates TLS, and forwards plain HTTP to your process on a port you pick. Your job is to listen on that port and answer requests.
Pick a web service when:
- You have an API (Express, FastAPI, Rails, Django, Go, Rust, …).
- You’re using SSR (Next.js
next start, Nuxt server, SvelteKit Node adapter). - You need websockets, server-sent events, or any other long-lived connection.
- You need server-side auth, per-request rendering, or anything that touches a database directly.
Anything that needs to run code on a server per request is a web service.
A minimum viable Blueprint
services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start healthCheckPath: /healthSix fields. This step walks through the four behavioral rules - port binding, TLS, health checks, free-tier sleep - that decide whether this service comes up cleanly.
Rule 1: bind 0.0.0.0 and listen on $PORT
Render’s proxy reaches your process by connecting to its internal IP on a specific port. Two things have to be true for that to work:
- Bind to
0.0.0.0(all interfaces), notlocalhostor127.0.0.1. The proxy isn’t on the same loopback interface as your app -localhostblocks it. - Listen on the port in the
PORTenvironment variable. Render setsPORTfor you, defaulting to10000. Hardcoding3000works on your laptop and fails on Render.
flowchart LR client["HTTPS request"] edge["Render edge<br/>(TLS termination)"] proxy["Internal proxy"] app["Your process<br/>0.0.0.0:$PORT"] client -->|"443 over HTTPS"| edge edge -->|"plain HTTP"| proxy proxy -->|"0.0.0.0:10000"| app
The pattern looks the same in every language:
import express from "express";
const app = express();app.get("/health", (_req, res) => res.send("ok"));
const port = Number(process.env.PORT) || 10000;app.listen(port, "0.0.0.0", () => { console.log(`listening on 0.0.0.0:${port}`);});import osfrom fastapi import FastAPI
app = FastAPI()
@app.get("/health")def health(): return {"status": "ok"}
if __name__ == "__main__": import uvicorn port = int(os.environ.get("PORT", 10000)) uvicorn.run(app, host="0.0.0.0", port=port)Or as the startCommand:
uvicorn main:app --host 0.0.0.0 --port $PORTpackage main
import ( "fmt" "net/http" "os")
func main() { http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "ok") }) port := os.Getenv("PORT") if port == "" { port = "10000" } http.ListenAndServe("0.0.0.0:"+port, nil)}Rule 2: TLS terminates at the edge - speak plain HTTP
flowchart LR client["Browser"] edge["Render edge"] app["Your process"] client -->|"HTTPS<br/>(TLS to edge)"| edge edge -->|"plain HTTP<br/>(inside Render's network)"| app
Render handles TLS for you. The certificate is auto-provisioned and auto-renewed for *.onrender.com and any custom domain you add. Your process speaks plain HTTP to the edge proxy - don’t try to terminate TLS yourself unless you have a very specific reason to.
Two practical implications:
- Don’t redirect HTTP to HTTPS in your app. The edge does that already.
- Don’t read TLS-related request properties (
req.connection.encrypted, etc.). They reflect the edge-to-app hop, which is plain HTTP. UseX-Forwarded-Proto: httpsif you need the original scheme.
Rule 3: health checks gate deploys
healthCheckPath: /healthRender hits this path with HTTP GET after your service starts. Any 2xx or 3xx response counts as healthy. A 4xx, 5xx, or no response at all means the deploy is unhealthy and won’t take traffic.
Three things to know:
- Health checks block rollouts. A new deploy with a failing health check won’t go live - the previous version keeps serving traffic. This is what makes zero-downtime deploys possible.
- Probe interval and timeout are configurable in the Render Dashboard. Defaults are sane; you’ll only need to tune them for slow-starting services or pathological dependencies.
- Keep the path cheap. Don’t put auth on it. Don’t do a database query on every probe - that turns a slow database into a deploy outage.
A bare-bones health endpoint that almost always works:
app.get("/health", (_req, res) => res.send("ok"));If you do want a deeper “ready to serve traffic” check (DB connection, dependent service reachable), put it on a separate path like /ready and keep /health shallow. Render uses /health to gate the deploy; /ready is for your own monitoring.
Rule 4: the free-tier sleep gotcha
The single most surprising thing about free-tier web services on Render: they spin down after 15 minutes of no inbound traffic. The next request wakes them up, which takes a few seconds - sometimes more, depending on your build.
flowchart LR active["Active<br/>(running, serving requests)"] idle["Idle 15+ min"] asleep["Spun down<br/>(no instance, no cost)"] cold["Next request:<br/>cold start"] active --> idle --> asleep asleep --> cold --> active
This is fine for hobby projects, demos, and proofs of concept. It’s not fine for production traffic, scheduled jobs that hit your endpoint, or anything that expects sub-second response times consistently.
Free-tier web service compromises:
| Limitation | Workaround |
|---|---|
| Spins down after 15 min idle | Upgrade to a paid plan |
| Cold starts on first request after sleep | Same |
| No custom domains on the free instance type | Same |
| Lower compute/memory limits | Same |
Static sites avoid all of these - they’re served from the CDN, no spin-down, free custom domains. That’s a big part of why “ship the frontend as a static site” wins so often.
Pre-deploy commands and rollouts
Most services need migrations or asset preparation before traffic hits a new version. The preDeployCommand runs against the new image before traffic switches over:
services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build preDeployCommand: npm run db:migrate startCommand: npm start healthCheckPath: /healthThe full lifecycle:
flowchart LR build["1. Build<br/>(buildCommand)"] pre["2. Pre-deploy<br/>(migrations etc.)"] start["3. Start<br/>(startCommand)"] hc["4. Health check<br/>(2xx/3xx)"] swap["5. Traffic swap"] drain["6. Drain old<br/>(maxShutdownDelaySeconds)"] build --> pre --> start --> hc --> swap --> drain
If preDeployCommand fails, the deploy is cancelled and the previous version keeps serving - which is what you want from a failed migration. If the health check fails, same thing: rollout doesn’t go live, previous version stays up.
A complete web service Blueprint
services: - type: web name: api runtime: node plan: starter buildCommand: npm ci && npm run build preDeployCommand: npm run db:migrate startCommand: npm start healthCheckPath: /health autoDeployTrigger: commit envVars: - key: NODE_ENV value: production - key: DATABASE_URL fromDatabase: name: app-db property: connectionString
databases: - name: app-db plan: basic-256mbThat’s a real, deployable API. Six lines of service config plus a database.
What you learned
- Bind `0.0.0.0` (not `localhost`) and read `PORT` from the environment - defaults to `10000`
- TLS terminates at Render's edge; your process speaks plain HTTP. Don't redirect HTTP→HTTPS in app code
- `healthCheckPath` returns 2xx/3xx for healthy. Failing checks block the rollout - keep the path cheap
- Free-tier web services spin down after 15 min of inactivity and don't get custom domains. Pay for production-grade
- `preDeployCommand` runs before traffic switches - perfect for migrations. A failure cancels the deploy