Learn how to containerize a Node.js Express API with Docker — Dockerfile best practices, multi-stage builds, and Docker Compose for local development.

Abdur Razzak
Full-Stack Web Developer
Docker packages your Node.js application and all its dependencies into a container that runs identically in development, CI, and production. The classic 'it works on my machine' problem disappears. Containers also make deployment simpler — deploy the same Docker image to any cloud platform that supports containers: AWS ECS, Google Cloud Run, DigitalOcean App Platform, or Railway.
Start your Dockerfile with FROM node:20-alpine for a small base image. Copy package.json and package-lock.json first, then run npm ci (not npm install) for reproducible installs. Copy your source files after installing dependencies — this leverages Docker's layer caching so a code change doesn't reinstall all packages. Set CMD ['node', 'dist/server.js'] as the startup command.
Use multi-stage builds to separate the build environment from the production image. Stage 1 (builder): install all dependencies including devDependencies and compile TypeScript. Stage 2 (production): start from a fresh node:20-alpine image, copy only the compiled JavaScript and production node_modules from the builder stage. The final image is much smaller — no TypeScript compiler, no test libraries, no build tools.
Create a docker-compose.yml that defines your Node.js service, MongoDB service, and Redis service. Use named volumes for MongoDB data persistence. Set environment variables in a .env file referenced by docker-compose. Mount your source code as a volume with hot-reload using nodemon inside the container. Run everything with docker-compose up — one command starts your entire development environment.
Create a .dockerignore file (similar to .gitignore) to exclude node_modules, .git, .env files, and test files from the build context. Never include .env files with secrets in your Docker image. Pass secrets to containers via environment variables at runtime, not build time. Run your Node.js process as a non-root user: USER node in your Dockerfile to limit the impact of a container escape vulnerability.
Push your Docker image to a container registry: Docker Hub (public), GitHub Container Registry, or AWS ECR. Tag your images with semantic versions and the git commit SHA for traceability. Build and push in your CI/CD pipeline (GitHub Actions) automatically on every merge to main. Pull the specific tagged image in your production deployment to ensure you are running exactly what was tested.