All posts
DevOpsJun 18, 2026·8 min read

How to Deploy a Node.js App with Docker

A complete walkthrough: writing a production Dockerfile, multi-stage builds, health checks, environment variables, and getting your image into a container registry and running on a server.

Why Docker for Node.js?

Docker solves the oldest problem in software deployment: environment inconsistency. "It works on my machine" stops being an excuse when the machine is a container image that runs identically on your laptop, in CI, and in production. Docker also enables immutable deployments — the image that passed your test suite is the exact image that runs in production, byte for byte.

For Node.js specifically, Docker gives you control over the exact Node version (no more NVM version mismatches across the team), isolated per-service environments, reproducible dependency installs via lockfiles, and a single artifact that your entire deployment pipeline can track by SHA digest.

The complete production Dockerfile

Here's the full, production-ready pattern. Three stages: dependency installation, build, and the lean runtime image.

# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
# npm ci installs exact versions from lockfile — never npm install in CI/prod
RUN npm ci --omit=dev

# Stage 2: Build (TypeScript compile, asset bundling, etc.)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                    # Include devDependencies for the build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build             # Compile TypeScript, bundle, etc.

# Stage 3: Lean runtime image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000

# Create a non-root user
RUN addgroup --system --gid 1001 nodejs  && adduser --system --uid 1001 --ingroup nodejs appuser

# Copy only what's needed at runtime
COPY --from=deps --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --chown=appuser:nodejs package.json .

USER appuser
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3   CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "--max-old-space-size=400", "dist/index.js"]

Why three stages?

The three-stage pattern keeps image size minimal by preventing build tools from ending up in the final image. Stage 1 installs production dependencies. Stage 2 installs all dependencies (including devDependencies) and runs the build. Stage 3 starts fresh and copies only the compiled output and production node_modules. Your TypeScript compiler, test frameworks, and build tools don't ship to production.

A typical Node.js API with this pattern produces a final image around 120–180MB. Without multi-stage builds, including devDependencies, the same app might be 800MB+.

The .dockerignore file — don't skip this

Without a .dockerignore, Docker copies your entire project directory into the build context — including node_modules (hundreds of MB), .git history, local .env files, and test coverage. This bloats your build context, slows down every build, and risks leaking secrets.

# .dockerignore
node_modules
dist
.git
.gitignore
.env
.env.*
*.test.ts
*.spec.ts
coverage
.nyc_output
*.md
Dockerfile*
docker-compose*
.eslintrc*
.prettierrc*
tsconfig*.json   # Only needed for build, not runtime

Alpine vs Debian: when to use which

node:20-alpine produces images 3–10x smaller than node:20 (Debian). Use Alpine unless:

  • You have npm packages with native C++ bindings (bcrypt, sharp, canvas) that require build tools. Fix: add RUN apk add --no-cache python3 make g++ in the build stage only.
  • Your app uses system libraries only available on Debian/Ubuntu. In this case, use node:20-slim as a compromise — Debian-based but stripped down to ~200MB.

For pure JavaScript/TypeScript Node.js APIs with no native modules, Alpine is always the right choice.

Environment variable injection — never bake secrets

Secrets committed to a Docker image are exposed to anyone who can pull that image. The correct pattern: inject at runtime, never at build time.

# ✗ Never do this — the secret is burned into every layer of the image
ENV DATABASE_URL=postgres://user:secret@prod-host/db

# ✓ Inject from a .env file at runtime
docker run --env-file .env.production myapp:latest

# ✓ Inject individually
docker run   -e DATABASE_URL="$DATABASE_URL"   -e JWT_SECRET="$JWT_SECRET"   myapp:latest

# ✓ In production: use your platform's secret injection
# (Cloudoku, ECS task definitions, Kubernetes Secrets, etc.)
# The container gets the env vars without you managing them in Docker commands

Layer caching: make your builds fast

Docker rebuilds from the first changed instruction. Structure your Dockerfile so slow, rarely-changing steps come first:

# SLOW: npm install runs on every code change
COPY . .
RUN npm ci          # ← always invalidated

# FAST: npm install only runs when package.json changes
COPY package*.json ./
RUN npm ci          # ← cached unless packages changed
COPY . .            # ← only this layer and below rebuild on code changes

With proper ordering, a typical build where only source code changed takes 10–15 seconds (only the COPY + compile layers rebuild). Without it, every build reinstalls all npm packages, taking 60–90 seconds.

Health checks in Docker

The HEALTHCHECK instruction tells Docker how to test whether the container is working. Orchestrators use this to delay routing traffic to new containers and to restart unhealthy ones.

# Using wget (built into Alpine)
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3   CMD wget -qO- http://localhost:3000/health || exit 1

# Options explained:
# --interval=30s      Check every 30 seconds
# --timeout=5s        Fail if check takes more than 5 seconds
# --start-period=15s  Don't count failures for the first 15s (startup time)
# --retries=3         Mark unhealthy after 3 consecutive failures

Building, tagging, and pushing images

# Build with a specific tag (use git SHA for immutable versioning)
docker build -t myapp:$(git rev-parse --short HEAD) .

# Also tag as latest for convenience
docker tag myapp:$(git rev-parse --short HEAD) myapp:latest

# Build for multiple platforms (Apple Silicon + Linux AMD64)
docker buildx build   --platform linux/amd64,linux/arm64   -t registry.example.com/myapp:$(git rev-parse --short HEAD)   --push .

# Push to a registry
docker push registry.example.com/myapp:latest

Best practice: Always tag images with the git commit SHA in your CI pipeline. :latest is ambiguous — you can't roll back to a specific version with it. The SHA is immutable and traceable.

Security scanning before push

Scan your image for known CVEs before pushing to production. A Node 20 Alpine image typically starts with zero high-severity vulnerabilities.

# Docker Scout (built into Docker Desktop)
docker scout cves myapp:latest

# Trivy (free, comprehensive, CI-friendly)
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest

# In GitHub Actions:
- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:latest
    exit-code: '1'
    severity: 'HIGH,CRITICAL'

Running the container locally

# Run with env file, expose port 3000, auto-remove on stop
docker run --rm   --env-file .env.local   -p 3000:3000   --name myapp-dev   myapp:latest

# View logs
docker logs -f myapp-dev

# Run a shell inside the container for debugging
docker exec -it myapp-dev sh

# Inspect resource usage
docker stats myapp-dev

Common mistakes and how to avoid them

  • Running as root — One RCE exploit gives an attacker root access to your container and potentially the host. Always create and use a non-root user.
  • No .dockerignore — Your image includes the full git history, local env files, and test coverage. Add the file.
  • NODE_ENV not set to production — Express disables several performance optimizations, enables verbose error output, and skips view caching in non-production mode. Always set ENV NODE_ENV=production.
  • Not setting --max-old-space-size — Node will try to use more memory than the container has, triggering an OOM kill with no useful log message.
  • Hardcoded PORT — Always read from process.env.PORT. Container platforms assign ports dynamically.
  • Copying package-lock.json but using npm install — Use npm ci. It respects the lockfile and is 2x faster than npm install in CI.

Production deployment

Most teams don't manage Docker directly in production. Instead, they push images to a container registry (Docker Hub, ECR, GCR) and let a platform handle orchestration — whether that's Kubernetes, ECS, or a PaaS like Cloudoku. The Dockerfile and image are identical regardless of the platform; only the deployment target changes. This means you can run the exact same image locally with docker run and in production with zero modifications.

Ready to put this into practice?

Deploy your Node.js app to production in minutes — zero YAML, automatic CI/CD, and HTTPS included.