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
- Use Alpine images —
node:22-alpine(150MB vs 1GB for full) - Multi-stage builds — smaller final image
- Don't run as root — create a non-root user
- Copy package.json first — leverage Docker layer caching
- Use .dockerignore — exclude unnecessary files
- Pin versions —
node:22.12-alpine, notnode:latest - 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.