Node.js is one of the most frequently containerized runtimes due to its dynamic ecosystem, ranging from simple REST APIs to WebSocket-based real-time applications. However, containerizing a Node.js application that merely runs is far different from containerizing one that is truly ready for daily developer use or deployment to production by Sysadmins/DevOps Engineers. This chapter covers a complete case study: from composing an efficient Dockerfile, connecting the application to a database container, building a development workflow with hot reload, to configurations suitable for production use such as graceful shutdown and health checks.
28.1 Containerizing a Node.js Application
The first step in containerizing a Node.js application is ensuring that the Dockerfile correctly utilizes Docker's layer cache mechanism, as dependency installation via npm is typically the longest stage in the build process.
28.1.1 Project Structure and Dependencies
The case study in this chapter uses a simple Express application with the following project structure.
myapp/
├── src/
│ └── server.js
├── package.json
├── package-lock.json
└── DockerfileThe package.json file defines a start script to run the application and a dev script for development mode with hot reload.
{
"name": "myapp",
"version": "1.0.0",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"dependencies": {
"express": "^4.19.2",
"pg": "^8.11.5"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}The pg package is used for connecting to PostgreSQL, while nodemon is placed in devDependencies because it is only needed during development, not when the application runs in production.
28.1.2 Multi-Stage Dockerfile for Node.js
The official node base image is available in several variants, and the alpine variant is a popular choice due to its much smaller size compared to a full Debian image. The following Dockerfile uses a multi-stage build pattern so that development dependencies like nodemon are not carried over into the final image.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]The npm ci command is used instead of npm install because, according to official npm documentation, npm ci installs dependencies exactly as specified in package-lock.json without modifying it, removes existing node_modules beforehand, and fails explicitly if package.json and package-lock.json are out of sync. This behavior makes builds more deterministic and suitable for automated environments like image builds or CI/CD pipelines, compared to npm install which can silently update package-lock.json.
The deps stage uses the --omit=dev option so that only production dependencies are installed, while the production stage simply copies the installation results via COPY --from=deps without re-running npm install. The USER node instruction leverages a non-root user named node already provided by the official Node.js base image, reducing the risk if a vulnerability in the application is exploited by an attacker to gain root access inside the container.
28.1.3 .dockerignore and Build Context
Without a .dockerignore file, the COPY instruction risks accidentally copying the local node_modules directory or .env files that should not be included in the image.
node_modules
npm-debug.log
.env
.git
Dockerfile
.dockerignoreBuild the image with a clear tag, then verify its size.
docker build -t myapp:1.0 .
docker images myappRun the built container and ensure the application responds on the exposed port.
docker run -d -p 3000:3000 --name myapp myapp:1.0
curl http://localhost:3000If the container stops immediately after running, check its logs first before guessing the cause.
docker logs myappA common error at this stage is Cannot find module, usually because the node_modules in the deps stage is inconsistent with the Node.js version used by the final stage, especially for packages with compiled native bindings.
28.2 Setting Up the Database Container
Node.js applications rarely stand alone without a database. This section connects the Express application to a PostgreSQL container using Docker Compose so both services can be managed as a single unit.
28.2.1 PostgreSQL with Docker Compose
Define the app and db services in a single compose.yaml file.
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://myapp:secret@db:5432/myapp
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 3s
retries: 5
volumes:
db-data:The condition: service_healthy option in depends_on ensures that the app service runs only after db is fully ready to accept connections, not just after its container has started. Without this health check, depends_on only guarantees the container startup order, not the readiness of the service inside it, which could lead to the application attempting to connect to a database that is still initializing and failing on the first attempt.
docker compose up -d28.2.2 Database Connection via Environment Variables
The application reads the connection string from the DATABASE_URL environment variable rather than hardcoding it in the codebase.
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = pool;Note that the hostname in DATABASE_URL uses the service name db instead of localhost. Docker Compose automatically provides DNS resolution between services on a default network based on the service names defined in compose.yaml, allowing app to reach db using that name without needing to know the container's internal IP address.
Verify that the connection is successful by inspecting the application container environment variables or checking the application logs directly.
docker compose logs app28.2.3 Data Persistence with Named Volumes
The db-data volume mounted to /var/lib/postgresql/data ensures PostgreSQL data persists even if the db container is removed and recreated, as the data is actually stored in a named volume managed by Docker, not in the ephemeral writable layer of the container.
docker compose down
docker compose up -d
docker compose exec db psql -U myapp -d myapp -c "\dt"Previously created tables remain present even after the db container was recreated by the docker compose down and up commands. Note that docker compose down -v will also delete volumes, including db-data, so avoid using the -v option in production unless you intentionally want to permanently remove all data.
28.3 Development Workflow
Daily development requires a fast edit-save-view loop. Rebuilding the image every time a line of code changes is inefficient, so developers need a workflow that reflects code changes immediately without a rebuild.
28.3.1 Hot Reloading with Nodemon and Bind Mounts
Create a compose.override.yaml file specifically for development, separate from the production configuration.
services:
app:
command: npm run dev
volumes:
- ./src:/app/src
environment:
NODE_ENV: developmentThe bind mount at ./src:/app/src links the host's src directory directly to the same directory inside the container, so any file change on the host is immediately reflected inside the container without rebuilding the image. Combine this with nodemon, which watches file changes and automatically restarts the Node.js process whenever a file is modified.
docker compose upDocker Compose automatically reads both compose.yaml and compose.override.yaml without requiring extra arguments, as according to official Docker Compose documentation, the compose.override.yaml file in the same directory is merged into the main compose.yaml file by default. Edit src/server.js, save it, and observe the nodemon logs displaying the automatic restart process.
28.3.2 Managing node_modules with Anonymous Volumes
A common pitfall developers encounter when using bind mounts for source code is that the host's node_modules (if present, or empty if local npm install hasn't been run) overwrites the node_modules installed inside the image when the container runs. Add an anonymous volume specifically for node_modules to ensure that directory retains the contents from inside the image rather than the host.
services:
app:
command: npm run dev
volumes:
- ./src:/app/src
- /app/node_modules
environment:
NODE_ENV: developmentThe /app/node_modules line without a host source path causes Docker to allocate a dedicated empty volume for that path inside the container, effectively protecting the node_modules contents generated by npm ci during image build from being overwritten by the src directory bind mount above it. If new dependencies are added to package.json, running docker compose build is still required to update node_modules inside the image.
docker compose build app
docker compose up -d28.4 Production Deployment
A configuration convenient for development is not necessarily secure and stable for production. This section addresses adjustments Sysadmins/DevOps Engineers must make before a Node.js application is ready to handle real traffic.
28.4.1 Minimal Production Image and NODE_ENV
The production image uses the multi-stage build result without nodemon and without any bind mounts, according to the previously structured Dockerfile. Explicitly set the environment variable NODE_ENV=production, as many frameworks and libraries in the Node.js ecosystem, including Express, use this variable to enable optimizations like view caching and disable verbose error messages that could leak internal application details to an attacker.
docker build -t myapp:1.0 --target production .
docker run -d \
-p 3000:3000 \
-e NODE_ENV=production \
-e DATABASE_URL=postgres://myapp:secret@db:5432/myapp \
--name myapp-prod \
myapp:1.0The --target production option ensures docker build stops at the production stage and excludes development dependencies, even if the Dockerfile is the same as the one used for development. Explicitly pass --target production every time you build an image for production so that nodemon and other development dependencies required in the build stage do not end up in the final image.
28.4.2 Graceful Shutdown and Signal Handling
When an orchestrator like Docker Swarm or Kubernetes stops a container, a SIGTERM signal is sent first by default to request a graceful process termination, followed by a forced SIGKILL if the process fails to stop within a specified timeout. Catch this signal in your application code so database connections and active requests can complete properly before the process exits.
const port = process.env.PORT || 3000;
const server = app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
process.on('SIGTERM', () => {
console.log('SIGTERM received, closing server...');
server.close(() => {
pool.end();
process.exit(0);
});
});Ensure the CMD instruction in the Dockerfile is written in exec form (["node", "src/server.js"]), not shell form (node src/server.js without an array). According to the official Dockerfile reference documentation, shell form executes the command via /bin/sh -c, causing the node process to run as a child process of the shell rather than PID 1. As a result, SIGTERM signals sent by Docker to PID 1 are not automatically forwarded to the underlying Node.js process, causing the container to stop only after the grace period expires via a forced SIGKILL, rather than shutting down gracefully as coded.
Test this behavior by sending a SIGTERM signal manually and observing the logs.
docker stop myapp-prod
docker logs myapp-prodThe logs should display the message SIGTERM received, closing server... before the container fully stops, rather than shutting down abruptly without exit logs.
28.4.3 Health Checks and Orchestration Readiness
Add a dedicated endpoint for application health checks, separate from main business endpoints.
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});Define a HEALTHCHECK instruction in the Dockerfile so the Docker daemon can monitor application status internally via health check mechanisms, rather than only verifying whether the container process is alive.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"The health check command above intentionally uses the built-in Node.js http module via node -e instead of curl or wget, because the node:20-alpine base image does not include these utilities by default. Adding them solely for health checks inflates the image size without providing additional benefit. Verify health status via docker ps or docker inspect after the container has run long enough to pass the --start-period.
docker inspect --format='{{.State.Health.Status}}' myapp-prodIf the status shows unhealthy despite the application behaving normally when accessed manually, verify whether the /health endpoint requires an unready database connection. Health checks that depend on external services risk marking the container as unhealthy during temporary database disruptions, rather than when the Node.js application itself is failing.

