Deploying Node.js to production: Docker, CI/CD and Linux — the complete guide

What it takes to run Node.js in production: production-ready code, Docker, Nginx and HTTPS, CI/CD to AWS, zero-downtime releases and safe database migrations.

Getting a Node.js app running on your laptop is the easy part. Production asks harder questions. What happens when the process crashes at 3 a.m.? How does a new version go live without dropping requests? Who renews the TLS certificate? What happens to the database when a deploy goes wrong?

This guide walks through the whole stack in the order you'd build it, from code that's ready for production to a pipeline that ships it safely. Each section gives you the essentials and links to a deep dive with the full configuration. It's the setup I use for Node.js and NestJS APIs on Linux, Docker and AWS, and the same approach that took one team's deploys from 45 minutes to under 8.

TL;DR#

  1. Make the app production-ready: config from validated environment variables, a real health check, graceful shutdown on SIGTERM, logs to stdout.
  2. Package it in Docker with a multi-stage build, so the image you test is the image you run.
  3. Keep it running with Docker's restart policies, or PM2 on a single server.
  4. Put Nginx in front for HTTPS from Let's Encrypt, HTTP/2 and compression.
  5. Automate releases with GitHub Actions: test, build the image once, push it, release it.
  6. Release without downtime: start the new version beside the old one and switch traffic only when it's healthy.
  7. Change the database safely: migrations run once, before the release, and stay backward-compatible.
  8. Watch it and plan for the bad day: logs, metrics, alerts, rollbacks and tested backups.

The production stack at a glance#

Visitor
  └─ Nginx            HTTPS (Let's Encrypt), HTTP/2, compression, rate limits
      └─ Node.js      in a Docker container, restarted on failure
          └─ PostgreSQL / Redis

GitHub Actions:  test → build image → push to registry → run migrations → release → health check

Every piece below fits into that picture.

1. Make the app production-ready#

Before any infrastructure, the app itself needs four habits.

Configuration from the environment, checked at startup. Read settings from environment variables, and refuse to start if a required one is missing. A crash on boot is much easier to diagnose than a half-working app:

const required = ['DATABASE_URL', 'JWT_SECRET'];
const missing = required.filter((name) => !process.env[name]);

if (missing.length) {
  console.error(`Missing environment variables: ${missing.join(', ')}`);
  process.exit(1);
}

A health check that means something. "The process is running" isn't healthy; healthy means it can serve a real request. Check the dependencies it can't work without, and report the version, so a release can confirm the new build is the one answering:

app.get('/health', async (_req, res) => {
  try {
    await db.query('SELECT 1'); // your database client
    res.json({ status: 'ok', version: process.env.APP_VERSION });
  } catch {
    res.status(503).json({ status: 'unavailable' });
  }
});

Graceful shutdown. Docker, PM2 and every orchestrator stop the app with a signal, then kill it if it doesn't exit in time. Stop accepting new connections and let open requests finish:

const server = app.listen(Number(process.env.PORT) || 3000);

process.on('SIGTERM', () => {
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 9_000).unref(); // before Docker's 10-second SIGKILL
});

Logs to stdout, as JSON. Let Docker or PM2 collect and rotate them, and use a structured logger like pino so you can search by field later.

And set NODE_ENV=production. In Express it caches view templates and sends less verbose errors, and many libraries skip development-only checks.

2. Package it with Docker#

A Docker image freezes your compiled app, its production dependencies, the exact Node.js version and the OS libraries underneath into one artifact. Build it once in CI, and the same bytes run in testing and production.

The essentials of a good Node.js Dockerfile:

  • A multi-stage build: install and compile in one stage, copy only the output and production dependencies into a slim final stage.
  • npm ci from the committed lockfile, with the manifests copied before the source so the install layer caches.
  • A non-root user and an exec-form CMD, so signals reach Node.js directly.
  • A .dockerignore that keeps node_modules, .git and .env files out of the build.

Deep dive: NestJS Dockerfile: multi-stage builds for production. The layout works the same for Express.

3. Keep it running#

Something has to restart the app when it crashes and start it again after a reboot. On a server that's Docker's restart policy (--restart unless-stopped) or PM2, a process manager that also adds cluster mode across CPU cores.

They solve different problems: PM2 manages processes, Docker packages the whole runtime. For a pipeline that ships the same artifact everywhere, I default to Docker; PM2 alone is fine for one app on one server.

Deep dive: PM2 vs Docker for running Node.js in production.

4. Put Nginx and HTTPS in front#

Run Node.js on 127.0.0.1 and let Nginx face the internet. It terminates TLS with a free Let's Encrypt certificate that renews itself, speaks HTTP/2, compresses responses, rate-limits abusive clients, and hosts several apps on one server.

Then tell the app it's behind a proxy (app.set('trust proxy', 1) in Express), or it sees every visitor as 127.0.0.1 over plain HTTP.

Deep dive: Nginx reverse proxy for Node.js with free SSL. Once you run more than one server, an AWS load balancer takes over part of this job: AWS ALB vs Nginx explains when you need which.

5. Automate it with CI/CD#

Every release should go through the same pipeline, with nobody running commands on the server by hand:

  1. Test on every push: install, lint, test.
  2. Build the image once, tagged with the commit SHA, and push it to a registry.
  3. Release that exact tag to the server, and fail the pipeline if the health check fails.

Three practices keep it safe. Use OIDC so GitHub Actions gets short-lived cloud credentials instead of stored keys. Use a concurrency group so releases queue instead of overlapping. And use a protected environment for production.

Speed matters too. A slow pipeline gets skipped. Cache dependencies and Docker layers, run independent jobs in parallel, and don't run the full pipeline for changes that can't affect it.

Deep dives:

6. Release without downtime#

Stopping the old version before starting the new one leaves a gap, and every request in it fails. A blue-green release avoids it on a single server: run the new version beside the old one, wait for its health check to pass and report the new version, switch Nginx to it with a reload (not a restart), then stop the old one. If the new version never becomes healthy, traffic never moves.

Rolling back is the same release with an older image tag.

Deep dive: Zero-downtime deploys for a Node.js API with Docker, Nginx and GitHub Actions.

7. Change the database safely#

Code rolls back in seconds; schema changes don't. Run migrations once, as their own release step, before the new code takes traffic, never on app startup. Keep each one compatible with the code that's already running: add first, remove in a later release. Use the safe forms of locking changes (CREATE INDEX CONCURRENTLY, constraints added NOT VALID) and set a lock_timeout.

Deep dive: Running database migrations safely in CI/CD.

8. Watch it in production#

You can't fix what you can't see. At minimum:

  • Logs from every instance in one place you can search, such as CloudWatch Logs.
  • Metrics: CPU, memory, response times and error rate, with the slowest requests (p95) rather than averages.
  • An uptime check that calls /health from outside your network, every minute.
  • Alerts that reach a person, on errors and on the uptime check failing.
  • Error tracking that groups exceptions with their stack traces, such as Sentry.

Deep dive: CloudWatch logs, metrics and alarms for a Node.js API.

9. Plan for the bad day#

  • Rollbacks are a release of the previous image tag. Know the command before you need it.
  • Backups only count once you've restored one and timed it. Bash scripts to automate Postgres backups to S3 includes the restore test.
  • A short runbook: where the logs are, how to roll back, who to tell.

The production checklist#

  • Required environment variables are validated at startup
  • /health checks real dependencies and reports the version
  • The app closes its server on SIGTERM within the stop timeout
  • Logs go to stdout, as JSON, and are rotated
  • NODE_ENV=production is set
  • The Docker image is multi-stage, runs as non-root and is built once in CI
  • The app restarts on crash and after a reboot
  • Nginx serves HTTPS with automatic certificate renewal, and the app trusts the proxy
  • CI tests every push; releases go through the pipeline, never by hand
  • Cloud credentials come from OIDC, not stored keys
  • Releases switch traffic only after a passing health check
  • Migrations run once, before the release, and stay backward-compatible
  • Errors and downtime alert a person
  • A rollback takes one command, and backups have been restored at least once

Need a hand?#

For the infrastructure underneath all of this, from the AWS account to the network and the bill, see AWS infrastructure for small SaaS teams: what you actually need. For the application itself, from module structure to multi-tenancy and query speed, see Building a scalable SaaS backend with NestJS and PostgreSQL.

Setting this up for your product is what I do: Docker, CI/CD pipelines, AWS, Linux servers and Nginx, from the first deploy onwards. See my DevOps and full-stack services, or email mhuzaifaa.dev@gmail.com.

FAQ#

What is the best way to deploy a Node.js app to production?#

Build a Docker image of the app in CI, tagged with the commit, and push it to a registry. Release that exact image to your server or platform, behind Nginx or a load balancer that handles HTTPS, and switch traffic only after its health check passes. Automate the whole process with a pipeline such as GitHub Actions.

Do I need Docker to deploy Node.js?#

No. You can install Node.js on a server and run the app with PM2. Docker adds a reproducible artifact: the same Node.js version, dependencies and system libraries everywhere. That makes releases, rollbacks and moving to platforms like ECS or Kubernetes much simpler.

Where should I host a Node.js app?#

It depends on how much you want to manage. A VPS or EC2 instance gives you full control at a low cost, but you run the server. Platforms like Render or Railway run your app with less setup. Container services like AWS ECS or Kubernetes suit several services that need to scale. A Docker image runs on all of them.

How do I deploy Node.js without downtime?#

Start the new version alongside the old one, wait until its health check passes, switch traffic to it, then stop the old version, letting it finish in-flight requests. On one server that's a blue-green switch with Nginx; orchestrators like ECS and Kubernetes do rolling updates. Database migrations must stay compatible with both versions.

What does NODE_ENV=production do?#

It tells Node.js libraries the app is running in production. Express, for example, caches view templates and returns less verbose error messages, and many libraries skip development-only checks and logging. Set it in the Dockerfile or the process manager's config, not in code.

Muhammad Huzaifa Abdulahad
Written by

Muhammad Huzaifa Abdulahad

Full-Stack Software & DevOps Engineer

I build SaaS platforms with Node.js, NestJS, TypeScript and PostgreSQL, and run them on Linux, Docker, AWS and CI/CD — from the first line of code to production.

Related articles

See all posts →

Have a complex idea? Let's build it together.

From MVPs to scalable products, let's turn your idea into reliable software built for real-world impact.