Deploying a Web Service
Deploying a Node/Express API to Render, end to end.
1. Prepare the App
Three Requirements
// 1. Listen on the injected PORT, bound to 0.0.0.0
const port = process.env.PORT || 3000;
const server = app.listen(port, "0.0.0.0", () => {
console.log(`Listening on ${port}`);
});
// 2. A health endpoint
app.get("/health", (req, res) => res.json({ status: "ok" }));
// 3. Graceful shutdown
process.on("SIGTERM", () => {
server.close(async () => {
await db.$disconnect();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
});Miss the first and your deploy succeeds but the service is unreachable. Miss the third and every deploy drops in-flight requests.
package.json
{
"engines": { "node": "20.x" },
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"worker": "node dist/worker.js"
}
}Pin engines — otherwise the platform default can change under you.
2. Create the Service
Dashboard → New → Web Service → connect the repository.
| Setting | Value |
|---|---|
| Runtime | Node |
| Build command | npm ci && npm run build |
| Start command | npm start |
| Health check path | /health |
| Auto-deploy | On (or off if you gate deploys through CI) |
| Region | Same region as your database |
Region Matters
Put the service in the same region as the database. A query to a database in another region adds 50–150ms — per query. A page making 10 queries becomes a second slower for no reason.
"Put compute near data" is the principle, and it applies to every platform.
3. Build Command
npm ci && npm run buildnpm ci, not npm install — it installs exactly the lockfile's tree, deletes node_modules first, and is faster. npm install can resolve to newer versions than you tested against.
With Prisma
npm ci && npx prisma generate && npm run buildprisma generate must run after install — the generated client lives inside node_modules and is wiped by npm ci.
Running Migrations
# Start command, or a pre-deploy command
npx prisma migrate deploy && npm startmigrate deploy applies pending migrations without prompting. Never migrate dev or db push in production — those can drop data.
The Multi-Instance Migration Problem
If you run migrations in the start command with three instances, all three try to migrate simultaneously. Prisma takes an advisory lock so it mostly works out, but the clean answer is Render's Pre-Deploy Command, which runs once before any instance starts.
Mentioning this shows you have thought past the happy path.
4. Environment Variables
Dashboard → Environment, or in render.yaml.
envVars:
- key: NODE_ENV
value: production
- key: DATABASE_URL
fromDatabase:
name: app-db
property: connectionString # internal URL, injected automatically
- key: JWT_SECRET
generateValue: true # Render generates it once
- key: STRIPE_SECRET_KEY
sync: false # set manually, never stored in gitEnvironment Groups
Share a set of variables across services — API, worker and cron all need the same DATABASE_URL. Define once, link to each service.
sync: false
Marks a variable as "set this in the dashboard". The key is declared in version control so nobody forgets it exists, but the value never touches git. A good pattern.
Note: unlike Vercel, backend variables here are read at runtime, so changing one and restarting is enough — no rebuild required.
5. Deploy and Verify
git push
→ build
→ new instance starts
→ health check must return 200
→ traffic shifts
→ old instance gets SIGTERMWatch the Logs
Dashboard → Logs, or:
render logs -r <service-id> --tailSmoke Test
curl -i https://your-api.onrender.com/health
curl -i https://your-api.onrender.com/api/usersCheck the status code and the security headers, not just that a response came back.
6. Background Workers
A queue consumer belongs in a separate service, not in the API process. A long job in the web process blocks request handling and makes autoscaling nonsensical — you'd be scaling web capacity to get job throughput.
- type: worker
name: job-worker
runtime: node
buildCommand: npm ci && npm run build
startCommand: npm run worker
envVars:
- fromGroup: shared-env// worker.js
import { Worker } from "bullmq";
const worker = new Worker("emails", async (job) => {
await sendEmail(job.data);
}, { connection: redis, concurrency: 5 });
process.on("SIGTERM", async () => {
await worker.close(); // finish the current job, stop taking new ones
process.exit(0);
});Three Things To Say About Workers
- Idempotent handlers — jobs will be delivered more than once. Design for it
- A dead-letter queue for permanently failing jobs, so they don't retry forever
- Graceful shutdown that finishes the current job — killing mid-job loses work
7. Cron Jobs
- type: cron
name: nightly-report
runtime: node
schedule: "0 3 * * *" # 03:00 UTC daily
buildCommand: npm ci && npm run build
startCommand: node dist/jobs/report.jsTwo Gotchas
1. The container exits when the command exits. Your script must actually finish — await everything and close connections. A script that leaves a handle open hangs the job.
2. Schedules are UTC. "3am" is 3am UTC, not 3am where you live. Off-by-hours bugs from this are common, especially across daylight saving changes.
Why Not setInterval in the Web Service?
With three instances, setInterval runs the job three times. Also, a redeploy resets the timer. A Cron Job service runs exactly once, in its own container.
This is a genuinely common bug and a good thing to mention.
8. Docker Deployment
For full control over the runtime.
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]- type: web
name: api
runtime: docker
dockerfilePath: ./DockerfileWhat Interviewers Check in a Dockerfile
| Point | Why |
|---|---|
| Multi-stage build | Build tools and source never reach the final image |
npm ci not npm install | Reproducible from the lockfile |
--omit=dev in the runner | Smaller image, smaller attack surface |
USER node | Not running as root — limits container escape damage |
| Alpine or slim base | Fewer packages, fewer CVEs |
Copy package*.json before source | Layer caching — dependencies only reinstall when they change |
.dockerignore | Keeps node_modules, .git and .env out of the build context |
The layer-caching point is worth explaining: copying package.json first means Docker reuses the cached npm ci layer whenever only source files changed. Copying everything first invalidates that cache on every commit.
9. Scaling
Vertical
Bigger instance. Simplest, and usually the right first move.
Horizontal
More instances. Requires a stateless app:
- Sessions → Redis, not memory
- Cache → Redis
- Rate limits → Redis (an in-memory limiter counts per instance)
- Uploads → S3 / R2, not the container filesystem
- Scheduled work → a Cron service, not
setInterval
Pool Sizing
instances × pool max < database connection limitThree instances × max: 20 = 60 connections. Check that against your plan before scaling up, or scaling out will take the database down instead of helping.
10. Monitoring and Debugging
Built In
Logs, CPU and memory graphs, event history, and health-check status.
What To Add
| Need | Tool |
|---|---|
| Error tracking | Sentry, with source maps uploaded |
| Structured logs | pino, shipped to Logtail or Datadog |
| Uptime | Better Uptime, Pingdom |
| Traces | OpenTelemetry |
Common Failures
| Symptom | Cause |
|---|---|
| Deploy succeeds, service unreachable | Not listening on process.env.PORT, or bound to 127.0.0.1 |
| Health check failing | Wrong path, or the app takes longer to boot than the timeout |
| Restart loop | Health endpoint checks the database, and the database blipped |
| Out of memory | Instance too small, or a leak — check the memory graph for a sawtooth vs a steady climb |
too many connections | Pool size × instances exceeds the plan |
| Requests dropped on deploy | No SIGTERM handler |
| Build fails, works locally | Case-sensitive imports, or a build dependency in devDependencies |
Memory Leak vs Undersized Instance
A sawtooth graph (rises, drops at GC, rises again) is normal. A steady climb that never drops, ending in a restart, is a leak.
Usual suspects: event listeners added per request without removal, an unbounded in-memory cache or array, or timers that are never cleared. Being able to describe the difference between the two graph shapes is a good, concrete answer.