Deploying a MongoDB Sharded Cluster with Docker Compose: Data Sharding for Large-Scale Applications

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

When Does MongoDB Sharding Actually Become Necessary?

About eight months ago, I was maintaining an e-commerce system running on a MongoDB single instance. Everything ran smoothly until the dataset crossed 50 GB and concurrent users climbed past 3,000. Real-time query latency kept creeping up — from 50ms to 2–3 seconds. Vertical scaling (adding RAM and CPU) bought a few weeks at most before the bottleneck came back.

That’s when I got serious about MongoDB Sharded Cluster. For a test environment, Docker Compose is the fastest approach: spin up all 10 containers in about 2 minutes, tear everything down cleanly when something’s misconfigured, no extra servers required.

Architecture: 3 Core Components

  • Config Servers: Stores cluster metadata — which shard owns which data range. Runs as a 3-node replica set.
  • Shard Servers: Where data actually lives. Each shard is a 3-node replica set for high availability.
  • mongos (Query Router): The single entry point — clients connect here and mongos automatically routes queries to the correct shard.

Setting Up the Environment

Minimum Requirements

  • Docker Engine 24+ and Docker Compose v2
  • At least 4 GB RAM (8 GB recommended for realistic testing)
  • MongoDB 7.0

Create the project directory and keyfile — required for MongoDB instances to authenticate with each other within a replica set:

mkdir mongo-sharded && cd mongo-sharded
mkdir -p config/keyfile scripts

openssl rand -base64 756 > config/keyfile/mongo-keyfile
chmod 400 config/keyfile/mongo-keyfile

The docker-compose.yml File

The full cluster includes: 3 config servers, 6 shard servers (2 shards × 3 nodes each), and 1 mongos router.

version: '3.8'

networks:
  mongo-cluster:
    driver: bridge

x-mongo-common: &mongo-common
  image: mongo:7.0
  restart: unless-stopped
  networks:
    - mongo-cluster

services:
  # --- Config Servers ---
  configsvr1:
    <<: *mongo-common
    container_name: configsvr1
    command: mongod --configsvr --replSet configReplSet --port 27017 --keyFile /etc/mongo/keyfile
    volumes:
      - configsvr1_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro
    ports:
      - "27119:27017"

  configsvr2:
    <<: *mongo-common
    container_name: configsvr2
    command: mongod --configsvr --replSet configReplSet --port 27017 --keyFile /etc/mongo/keyfile
    volumes:
      - configsvr2_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  configsvr3:
    <<: *mongo-common
    container_name: configsvr3
    command: mongod --configsvr --replSet configReplSet --port 27017 --keyFile /etc/mongo/keyfile
    volumes:
      - configsvr3_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  # --- Shard 1 ---
  shard1rs1:
    <<: *mongo-common
    container_name: shard1rs1
    command: mongod --shardsvr --replSet shard1ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard1rs1_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro
    ports:
      - "27121:27017"

  shard1rs2:
    <<: *mongo-common
    container_name: shard1rs2
    command: mongod --shardsvr --replSet shard1ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard1rs2_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  shard1rs3:
    <<: *mongo-common
    container_name: shard1rs3
    command: mongod --shardsvr --replSet shard1ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard1rs3_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  # --- Shard 2 ---
  shard2rs1:
    <<: *mongo-common
    container_name: shard2rs1
    command: mongod --shardsvr --replSet shard2ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard2rs1_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro
    ports:
      - "27122:27017"

  shard2rs2:
    <<: *mongo-common
    container_name: shard2rs2
    command: mongod --shardsvr --replSet shard2ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard2rs2_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  shard2rs3:
    <<: *mongo-common
    container_name: shard2rs3
    command: mongod --shardsvr --replSet shard2ReplSet --port 27017 --wiredTigerCacheSizeGB 0.5 --keyFile /etc/mongo/keyfile
    volumes:
      - shard2rs3_data:/data/db
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro

  # --- Query Router ---
  mongos:
    <<: *mongo-common
    container_name: mongos
    command: mongos --configdb configReplSet/configsvr1:27017,configsvr2:27017,configsvr3:27017 --port 27017 --keyFile /etc/mongo/keyfile
    volumes:
      - ./config/keyfile/mongo-keyfile:/etc/mongo/keyfile:ro
    ports:
      - "27017:27017"
    depends_on:
      - configsvr1
      - configsvr2
      - configsvr3
    deploy:
      resources:
        limits:
          memory: 1G
        reservations:
          memory: 512M

volumes:
  configsvr1_data:
  configsvr2_data:
  configsvr3_data:
  shard1rs1_data:
  shard1rs2_data:
  shard1rs3_data:
  shard2rs1_data:
  shard2rs2_data:
  shard2rs3_data:

The --wiredTigerCacheSizeGB 0.5 flag on each shard is a lesson learned from experience. On my first deployment I omitted it entirely — no limits whatsoever. After about 3 hours of load testing, the containers had consumed nearly all the host’s RAM. It took 2 days of debugging to identify the culprit. With Docker, memory limits are something you declare from the start, not something you bolt on after an outage.

Detailed Configuration After Startup

Start the full cluster:

docker compose up -d
# Wait ~30 seconds for instances to be ready

Initialize the replica set for Config Servers:

docker exec -it configsvr1 mongosh --port 27017 --eval '
rs.initiate({
  _id: "configReplSet",
  configsvr: true,
  members: [
    { _id: 0, host: "configsvr1:27017" },
    { _id: 1, host: "configsvr2:27017" },
    { _id: 2, host: "configsvr3:27017" }
  ]
})'

Initialize the replica sets for Shard 1 and Shard 2:

docker exec -it shard1rs1 mongosh --port 27017 --eval '
rs.initiate({
  _id: "shard1ReplSet",
  members: [
    { _id: 0, host: "shard1rs1:27017" },
    { _id: 1, host: "shard1rs2:27017" },
    { _id: 2, host: "shard1rs3:27017" }
  ]
})'

docker exec -it shard2rs1 mongosh --port 27017 --eval '
rs.initiate({
  _id: "shard2ReplSet",
  members: [
    { _id: 0, host: "shard2rs1:27017" },
    { _id: 1, host: "shard2rs2:27017" },
    { _id: 2, host: "shard2rs3:27017" }
  ]
})'

Register the shards with the cluster via mongos:

docker exec -it mongos mongosh --port 27017 --eval '
sh.addShard("shard1ReplSet/shard1rs1:27017,shard1rs2:27017,shard1rs3:27017");
sh.addShard("shard2ReplSet/shard2rs1:27017,shard2rs2:27017,shard2rs3:27017");'

Enabling Sharding for a Database and Collection

Sharding isn’t enabled automatically — you need to explicitly specify which databases and collections to shard. The single most critical step is choosing the shard key. This decision is nearly impossible to reverse: once a collection has data in it, changing the shard key means dumping everything out, dropping the collection, and re-importing from scratch.

docker exec -it mongos mongosh --port 27017 --eval '
// Enable sharding for the database
sh.enableSharding("myapp");

// Hashed sharding: even distribution, ideal for point queries on a specific ID
sh.shardCollection("myapp.orders", { user_id: "hashed" });

// Range-based: more efficient for range queries, but more prone to hotspots
// sh.shardCollection("myapp.logs", { timestamp: 1 });'

The rule of thumb: use hashed when you mostly query by a specific ID (user_id: 42), and range-based when queries span a range (timestamp between date A and date B). For an e-commerce orders collection, hashed on user_id gives more even distribution — no user segment dominates the traffic. For a logs collection, range-based on timestamp makes more sense, since the vast majority of queries retrieve logs from the past 7 days.

Verification and Monitoring

Viewing Cluster Status

# Cluster overview and chunk distribution
docker exec -it mongos mongosh --port 27017 --eval 'sh.status()'

# Detailed data distribution per shard
docker exec -it mongos mongosh --port 27017 --eval '
use myapp;
db.orders.getShardDistribution()'

Testing Real-World Distribution

docker exec -it mongos mongosh --port 27017 --eval '
use myapp;
for (let i = 0; i < 10000; i++) {
  db.orders.insertOne({
    user_id: i,
    product: "item_" + Math.floor(Math.random() * 100),
    amount: Math.random() * 1000,
    created_at: new Date()
  });
}
print("Total:", db.orders.countDocuments());
db.orders.getShardDistribution();'

After inserting 10,000 documents, getShardDistribution() will show data distributed fairly evenly across both shards. Initially the cluster has just 1 chunk — MongoDB splits and migrates chunks automatically as data grows.

Analyzing Queries with explain()

docker exec -it mongos mongosh --port 27017 --eval '
use myapp;
db.orders.find({ user_id: 42 }).explain("executionStats")'

Check the queryPlanner.winningPlan.shards field in the output: if only 1 shard appears — the query is targeted, mongos knows exactly which shard to hit. If both shards appear — it’s scatter-gather, mongos fans out to all shards and merges the results. With 2 shards the overhead is negligible. But scale up to 8–10 shards with scatter-gather queries and latency climbs, because you’re bottlenecked by the slowest shard. At that point you need to revisit your shard key or add appropriate indexes.

Comprehensive Health Check Script

#!/bin/bash
# scripts/health-check.sh
echo "=== Config Servers ==="
docker exec configsvr1 mongosh --quiet --eval \
  'rs.status().members.forEach(m => print(m.name, m.stateStr))'

echo "=== Shard 1 ==="
docker exec shard1rs1 mongosh --quiet --eval \
  'rs.status().members.forEach(m => print(m.name, m.stateStr))'

echo "=== Shard 2 ==="
docker exec shard2rs1 mongosh --quiet --eval \
  'rs.status().members.forEach(m => print(m.name, m.stateStr))'

echo "=== Cluster Shards ==="
docker exec mongos mongosh --quiet --eval \
  'sh.status()' | grep -E "(shards|currently|chunks)"

This cluster has been running in production for 6 months — 10 million documents, average query response time under 30ms. Two things make or break a sharded setup: choosing the right shard key upfront, and setting memory limits on every container. Neither can be retrofitted after the fact — I learned both lessons the hard way.

Share: