Render Tutorials
Web service or static site?

Web services - when you need a server

⏱ 7 min

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

render.yaml
services:
- type: web
name: api
runtime: node
plan: starter
buildCommand: npm ci && npm run build
startCommand: npm start
healthCheckPath: /health

Six 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), not localhost or 127.0.0.1. The proxy isn’t on the same loopback interface as your app - localhost blocks it.
  • Listen on the port in the PORT environment variable. Render sets PORT for you, defaulting to 10000. Hardcoding 3000 works 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:

server.js
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}`);
});
main.py
import os
from 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:

Terminal window
uvicorn main:app --host 0.0.0.0 --port $PORT
main.go
package 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. Use X-Forwarded-Proto: https if you need the original scheme.

Rule 3: health checks gate deploys

render.yaml
healthCheckPath: /health

Render 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:

LimitationWorkaround
Spins down after 15 min idleUpgrade to a paid plan
Cold starts on first request after sleepSame
No custom domains on the free instance typeSame
Lower compute/memory limitsSame

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:

render.yaml
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

The 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

render.yaml
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-256mb

That’s a real, deployable API. Six lines of service config plus a database.

Your Express app starts cleanly locally with `app.listen(3000, 'localhost')`. You deploy to Render with `startCommand: node server.js`. The deploy logs show 'listening on localhost:3000' but Render keeps reporting health checks fail. What's wrong?

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