← Back to articles

How to Deploy a Node.js App with Docker (2026)

Docker packages your Node.js app into a container that runs identically everywhere — your laptop, CI, staging, production. Here's the complete guide from Dockerfile to production deployment.

Basic Dockerfile

FROM node:22-alpine

WORKDIR /app

COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

COPY . .

RUN pnpm build

EXPOSE 3000
CMD ["node", "dist/index.js"]

Production Dockerfile (Multi-Stage)

Multi-stage builds create smaller, more secure images:

# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build

# Stage 3: Production image
FROM node:22-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

# Don't run as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

Result: ~150MB image vs ~500MB without multi-stage.

.dockerignore

node_modules
dist
.git
.env
.env.local
*.md
.github
tests
coverage

Docker Compose (Local Development)

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/myapp
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis
    volumes:
      - .:/app
      - /app/node_modules

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:
docker compose up -d     # Start all services
docker compose logs -f   # Watch logs
docker compose down      # Stop everything

Next.js Dockerfile

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && pnpm build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

Requires output: 'standalone' in next.config.js.

Deploy to Production

Railway

# Railway auto-detects Dockerfile
railway up

Fly.io

fly launch    # Creates fly.toml
fly deploy    # Builds and deploys Docker image

Any VPS (DigitalOcean, Hetzner)

# On your server
docker pull ghcr.io/youruser/yourapp:latest
docker run -d -p 3000:3000 --env-file .env ghcr.io/youruser/yourapp:latest

GitHub Actions CI/CD

name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and push Docker image
        run: |
          docker build -t ghcr.io/${{ github.repository }}:latest .
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/${{ github.repository }}:latest

      - name: Deploy to server
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} \
            "docker pull ghcr.io/${{ github.repository }}:latest && \
             docker stop myapp || true && \
             docker run -d --name myapp -p 3000:3000 --env-file .env ghcr.io/${{ github.repository }}:latest"

Health Checks

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
// app/health/route.ts
export function GET() {
  return Response.json({ status: 'ok', timestamp: Date.now() })
}

Best Practices

  1. Use Alpine imagesnode:22-alpine (150MB vs 1GB for full)
  2. Multi-stage builds — smaller final image
  3. Don't run as root — create a non-root user
  4. Copy package.json first — leverage Docker layer caching
  5. Use .dockerignore — exclude unnecessary files
  6. Pin versionsnode:22.12-alpine, not node:latest
  7. Health checks — let orchestrators know your app is healthy

FAQ

Docker vs serverless (Vercel)?

Docker for full control, WebSockets, long-running processes, and cost savings at scale. Vercel for zero-config Next.js deployment.

How big should my Docker image be?

Target: 100-200MB for Node.js apps. Over 500MB means you're including unnecessary files.

Should I use Docker in development?

Docker Compose for databases and services (Postgres, Redis). Run your Node.js app natively for faster hot reload.

Bottom Line

Docker deploys Node.js apps consistently everywhere. Use multi-stage builds for small images, Docker Compose for local development, and GitHub Actions for CI/CD. Deploy to Railway, Fly.io, or any VPS. The investment in learning Docker pays off on every project.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.