Dockerize a Node.js + MongoDB App with Docker Compose

In modern software development, the classic 'it works on my machine' dilemma continues to plague engineering teams as differences in operating systems, Node.js runtimes, and local database versions cause deployment friction. **Docker** and **Docker Compose** eliminate this environment drift by packaging applications and their backing services into isolated, reproducible containers.

This comprehensive guide walks you through building a production-grade Node.js and Express REST API connected to a MongoDB database, containerizing the application using multi-stage Dockerfiles, and orchestrating local development and scaling using Docker Compose.

1. Understanding Containerized Multi-Service Architecture

When building containerized microservices, running an application and a database requires managing network communication and data persistence across multiple isolated containers.

  • App Container (Node.js/Express): Runs the backend server code, handling client HTTP requests and interacting with the database driver.
  • Database Container (MongoDB): Stores persistent collections and documents, utilizing Docker volumes to ensure data survives container restarts.
  • Docker Compose: Acts as the multi-container orchestrator, defining both services, environment variables, internal bridge networks, and persistent storage volumes in a single `docker-compose.yml` file.

2. Setting Up the Node.js Express Application

Let's create a minimal Express server configured to connect to MongoDB using environment variables.

C
// server.js
const express = require('express');
const mongoose = require('mongoose');

const app = express();
app.use(express.json());

const PORT = process.env.PORT || 5000;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/testdb';

mongoose.connect(MONGO_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true
})
.then(() => console.log('MongoDB connected successfully'))
.catch(err => console.error('MongoDB connection error:', err));

app.get('/health', (req, res) => {
    res.status(200).json({ status: 'UP', timestamp: new Date() });
});

app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});

3. Writing a Production-Ready Multi-Stage Dockerfile

To keep production images lean and secure, we use a multi-stage build that separates development dependencies from production artifacts.

C
# Stage 1: Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# Stage 2: Production runtime stage
FROM node:20-alpine AS runner
WORKDIR /app

# Create a non-privileged system user for security
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nodejs -G nodejs

COPY package*.json ./
RUN npm ci --only=production

COPY --from=builder /app ./

USER nodejs
EXPOSE 5000
CMD ["node", "server.js"]

4. Orchestrating Services with Docker Compose

Create a `docker-compose.yml` file in your project root to tie the Node.js application and MongoDB together.

C
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: node_app
    restart: always
    ports:
      - "5000:5000"
    environment:
      - PORT=5000
      - MONGO_URI=mongodb://mongo:27017/codenest
    depends_on:
      - mongo
    networks:
      - app-net

  mongo:
    image: mongo:7.0
    container_name: mongodb
    restart: always
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db
    environment:
      - MONGO_INITDB_DATABASE=codenest
    networks:
      - app-net

volumes:
  mongo_data:

networks:
  app-net:
    driver: bridge

5. Running and Testing Your Containerized Stack

Spin up your complete development environment with a single terminal command and verify logs:

C
# Build and start containers in detached mode
docker compose up --d --build

# View container logs
docker compose logs -f

# Stop and clean up containers and networks
docker compose down

Conclusion

Containerizing a Node.js and MongoDB application with Docker Compose guarantees absolute environment consistency from local development to production servers. By leveraging multi-stage builds and persistent named volumes, engineering teams can achieve scalable, secure, and easily reproducible backend architectures.