Skip to content

Deploying a Web Service

Deploying a Node/Express API to Render, end to end.


1. Prepare the App

Three Requirements

js
// 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

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.

SettingValue
RuntimeNode
Build commandnpm ci && npm run build
Start commandnpm start
Health check path/health
Auto-deployOn (or off if you gate deploys through CI)
RegionSame 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

bash
npm ci && npm run build

npm 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

bash
npm ci && npx prisma generate && npm run build

prisma generate must run after install — the generated client lives inside node_modules and is wiped by npm ci.

Running Migrations

bash
# Start command, or a pre-deploy command
npx prisma migrate deploy && npm start

migrate 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.

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 git

Environment 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 SIGTERM

Watch the Logs

Dashboard → Logs, or:

bash
render logs -r <service-id> --tail

Smoke Test

bash
curl -i https://your-api.onrender.com/health
curl -i https://your-api.onrender.com/api/users

Check 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.

yaml
- type: worker
  name: job-worker
  runtime: node
  buildCommand: npm ci && npm run build
  startCommand: npm run worker
  envVars:
    - fromGroup: shared-env
js
// 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

  1. Idempotent handlers — jobs will be delivered more than once. Design for it
  2. A dead-letter queue for permanently failing jobs, so they don't retry forever
  3. Graceful shutdown that finishes the current job — killing mid-job loses work

7. Cron Jobs

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

Two 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.

dockerfile
# 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"]
yaml
- type: web
  name: api
  runtime: docker
  dockerfilePath: ./Dockerfile

What Interviewers Check in a Dockerfile

PointWhy
Multi-stage buildBuild tools and source never reach the final image
npm ci not npm installReproducible from the lockfile
--omit=dev in the runnerSmaller image, smaller attack surface
USER nodeNot running as root — limits container escape damage
Alpine or slim baseFewer packages, fewer CVEs
Copy package*.json before sourceLayer caching — dependencies only reinstall when they change
.dockerignoreKeeps 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 limit

Three 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

NeedTool
Error trackingSentry, with source maps uploaded
Structured logspino, shipped to Logtail or Datadog
UptimeBetter Uptime, Pingdom
TracesOpenTelemetry

Common Failures

SymptomCause
Deploy succeeds, service unreachableNot listening on process.env.PORT, or bound to 127.0.0.1
Health check failingWrong path, or the app takes longer to boot than the timeout
Restart loopHealth endpoint checks the database, and the database blipped
Out of memoryInstance too small, or a leak — check the memory graph for a sawtooth vs a steady climb
too many connectionsPool size × instances exceeds the plan
Requests dropped on deployNo SIGTERM handler
Build fails, works locallyCase-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.

© 2025 DDocs · Dipak's Documentation Guide