Docker Compose — Multi-Container MEAN Stack with Networks and Healthchecks

Docker Compose is the tool for defining and running multi-container Docker applications. With a single docker-compose.yml file you describe the complete MEAN Stack — Express API, Angular dev server, MongoDB, Redis for caching and sessions, and an nginx reverse proxy — and start everything with one command: docker compose up. Compose handles container creation, network setup, volume mounting, environment variables, dependency ordering, and health check integration automatically.

Docker Compose Key Fields

Field Purpose Example
services Define each container/service services: api: ...
image Use a pre-built image image: mongo:7
build Build from a Dockerfile build: { context: ./api, target: development }
ports Map host:container ports ports: ["3000:3000"]
volumes Mount volumes or host paths volumes: ["./api:/app", "node_modules:/app/node_modules"]
environment Set environment variables environment: { NODE_ENV: development }
env_file Load env vars from a file env_file: ./api/.env
depends_on Start order + health dependency depends_on: { mongodb: { condition: service_healthy } }
networks Connect to named networks networks: [backend, frontend]
healthcheck Test if service is ready test: ["CMD", "mongosh", "--eval", "db.runCommand({ping:1})"]
restart Restart policy restart: unless-stopped
Note: In Docker Compose networks, containers communicate using their service name as the hostname. The Express API connects to MongoDB using mongodb://mongodb:27017/taskmanager — where mongodb is the service name defined in docker-compose.yml. Docker’s built-in DNS resolves service names to container IP addresses automatically. You never need to hard-code IP addresses between services.
Tip: Use a named volume for node_modules inside the container when bind-mounting source code for hot reload. The pattern volumes: ["./api:/app", "node_modules:/app/node_modules"] mounts the host source directory into /app but keeps the container’s node_modules separate (in a named volume). Without this, the host’s node_modules (built for the host OS) would overwrite the container’s node_modules (built for Linux) — causing native module errors on macOS and Windows.
Warning: depends_on alone does not guarantee a service is ready before the dependent service starts — only that the container has started. Use depends_on: { mongodb: { condition: service_healthy } } with a healthcheck on the MongoDB service to ensure the Express API only starts after MongoDB is accepting connections. Without this, the API may crash on startup with “connection refused” because MongoDB is still initialising.

Complete Docker Compose Configuration

# docker-compose.yml — Development environment
version: '3.9'

services:

  # ── MongoDB ──────────────────────────────────────────────────────────────
  mongodb:
    image: mongo:7
    container_name: taskmanager_mongo
    restart: unless-stopped
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-admin}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASS:-secret}
      MONGO_INITDB_DATABASE:      taskmanager
    volumes:
      - mongodb_data:/data/db          # persist data across restarts
      - ./mongo-init.js:/docker-entrypoint-initdb.d/init.js:ro  # seed script
    ports:
      - "27017:27017"    # expose for MongoDB Compass (dev only)
    networks:
      - backend
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.runCommand({ping:1})", "--quiet"]
      interval: 10s
      timeout:  5s
      retries:  5
      start_period: 30s

  # ── Redis ─────────────────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: taskmanager_redis
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD:-redispass}
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redispass}", "ping"]
      interval: 10s
      timeout:  5s
      retries:  3

  # ── Express API ───────────────────────────────────────────────────────────
  api:
    build:
      context: ./api
      target:  development             # use the dev stage with nodemon
    container_name: taskmanager_api
    restart: unless-stopped
    env_file: ./api/.env
    environment:
      NODE_ENV:  development
      MONGO_URI: mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASS:-secret}@mongodb:27017/taskmanager?authSource=admin
      REDIS_URL: redis://:${REDIS_PASSWORD:-redispass}@redis:6379
    volumes:
      - ./api:/app                     # bind mount source for hot reload
      - api_modules:/app/node_modules  # named volume for container's node_modules
    ports:
      - "3000:3000"
    networks:
      - backend
    depends_on:
      mongodb:
        condition: service_healthy
      redis:
        condition: service_healthy

  # ── Angular Dev Server ────────────────────────────────────────────────────
  angular:
    build:
      context: ./client
      target:  development
    container_name: taskmanager_angular
    restart: unless-stopped
    environment:
      - CHOKIDAR_USEPOLLING=true       # required for file watching inside Docker on macOS/Windows
    volumes:
      - ./client/src:/app/src          # hot reload for source changes
      - ng_modules:/app/node_modules
    ports:
      - "4200:4200"
    networks:
      - frontend
    depends_on:
      - api

  # ── nginx reverse proxy ───────────────────────────────────────────────────
  nginx:
    image: nginx:alpine
    container_name: taskmanager_nginx
    restart: unless-stopped
    volumes:
      - ./nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "80:80"
    networks:
      - frontend
      - backend
    depends_on:
      - api
      - angular

# ── Volumes ───────────────────────────────────────────────────────────────
volumes:
  mongodb_data:
  redis_data:
  api_modules:
  ng_modules:

# ── Networks ──────────────────────────────────────────────────────────────
networks:
  backend:
    driver: bridge
  frontend:
    driver: bridge
# Common Docker Compose commands
docker compose up                    # start all services, attach logs
docker compose up -d                 # start detached (background)
docker compose up --build            # rebuild images before starting
docker compose down                  # stop and remove containers/networks
docker compose down -v               # also remove volumes (wipe data)
docker compose logs -f api           # follow logs for the api service
docker compose exec api sh           # open shell in running api container
docker compose ps                    # list service status
docker compose restart api           # restart one service
docker compose build api             # rebuild one service's image

# Override for production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

How It Works

Step 1 — Compose Creates an Isolated Network for All Services

When you run docker compose up, Compose creates a virtual network named projectname_default (plus any explicitly named networks in the file). Every service joins this network and gets a DNS entry matching its service name. The api service resolves mongodb to the MongoDB container’s IP, redis to the Redis container’s IP — no configuration needed beyond the service name.

Step 2 — depends_on with Healthcheck Ensures Correct Startup Order

depends_on: { mongodb: { condition: service_healthy } } makes the api service wait until MongoDB’s healthcheck passes before starting. The healthcheck runs mongosh --eval "db.runCommand({ping:1})" every 10 seconds. Once MongoDB responds successfully, the api container starts. This eliminates the race condition where Node.js tries to connect to MongoDB before it is ready to accept connections.

Step 3 — Named Volumes for node_modules Solve Cross-Platform Issues

When the host source directory is bind-mounted into the container, the host’s node_modules would also be visible inside the container — overriding the container’s clean install. The api_modules:/app/node_modules named volume mounts a separate, container-internal volume at /app/node_modules, shadowing the bind-mounted version. The container always uses its own node_modules, built for the Linux container OS.

Step 4 — CHOKIDAR_USEPOLLING Enables File Watching in Containers

File watching utilities (used by nodemon and the Angular CLI) typically use inotify (Linux file system events). When the source directory is bind-mounted from a macOS or Windows host, these events do not propagate into the container reliably. Setting CHOKIDAR_USEPOLLING=true switches to polling — checking file modification times periodically — which works across all host operating systems at the cost of slightly higher CPU usage.

Step 5 — Compose Overrides Enable Environment-Specific Configuration

docker compose -f docker-compose.yml -f docker-compose.prod.yml up merges two Compose files. The production override file only needs to specify what differs from the base file — different image tags, removed debug ports, added resource limits, changed restart policies. This avoids duplicating the entire configuration for each environment while keeping environment differences explicit and reviewable.

Common Mistakes

Mistake 1 — Using depends_on without healthcheck condition

❌ Wrong — api starts immediately after mongodb container starts (not when ready):

api:
    depends_on:
        - mongodb   # container started, but MongoDB may not be accepting connections!

✅ Correct — wait for healthy status:

api:
    depends_on:
        mongodb:
            condition: service_healthy   # waits for healthcheck to pass

Mistake 2 — Exposing MongoDB port in production

❌ Wrong — MongoDB is accessible from the internet in production:

mongodb:
    ports:
        - "27017:27017"   # in production — accessible externally!

✅ Correct — no ports exposure for internal services:

mongodb:
    # No ports — only accessible within the Docker network
    # api reaches it via: mongodb://mongodb:27017

Mistake 3 — Not using a named volume for node_modules

❌ Wrong — host’s macOS node_modules overwrite container’s Linux node_modules:

volumes:
    - ./api:/app   # host node_modules visible as /app/node_modules — wrong architecture!

✅ Correct — shadow with a named volume:

volumes:
    - ./api:/app
    - api_modules:/app/node_modules   # shadows the bind mount at this path

Quick Reference

Task Command
Start all services docker compose up -d
Rebuild and start docker compose up -d --build
Stop all services docker compose down
View logs docker compose logs -f servicename
Shell into service docker compose exec api sh
Run one-off command docker compose run --rm api npm test
Wipe volumes docker compose down -v
Service DNS Service name resolves as hostname: mongodb://mongodb:27017
Named volume for node_modules volumes: ["./src:/app", "node_mods:/app/node_modules"]

🧠 Test Yourself

The Express API service in Compose uses depends_on: [mongodb] (without a healthcheck condition). On first startup, the API crashes with “connection refused”. Why, and what is the fix?