Setup with Docker
The fastest way to get a real MongoDB to play with is Docker. The official mongo image gives you a clean, isolated server in one command, with nothing installed permanently on your machine and nothing to uninstall afterwards. This lesson gets you from “Docker is running” to “I can talk to MongoDB” - first with a one-liner, then with a small docker-compose.yml you can keep in your project - and it settles a question the later modules depend on: standalone or replica set?
You only need Docker Desktop (or the Docker engine) installed and running. Every command below was run for real on one Mac with mongo:8 = 8.3.11 (mongosh 2.11.1, Node driver 7.7.0). To keep the test containers tidy we ran them as mongodb-b1-dev and so on; the commands here use the friendlier mongo-dev you should use.
Standalone or replica set? Decide first
Section titled “Standalone or replica set? Decide first”A plain mongo container is a standalone server. That is perfect for modules 1 to 6. But multi-document transactions and change streams (module 7) only work on a replica set, and a one-node replica set is enough. Here is what the same client code does on a standalone container (real output):
txn: MongoServerError: This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.watch: MongoServerError: The $changeStream stage is only supported on replica sets or mongos (code 40573)So this lesson gives you both shapes. Use the standalone one for now; when you reach module 7, switch to the replica-set one (it works for every earlier module too, so if you prefer, just use it from the start).
Standalone (docker run / compose 1) | One-node replica set (compose 2) | |
|---|---|---|
| Modules 1-6 (CRUD, indexes, aggregation, modeling) | yes | yes |
| Transactions, change streams (module 7) | no | yes |
| Root user / auth | yes (MONGO_INITDB_ROOT_*) | no - mongod --replSet rs0 --auth exits with BadValue: security.keyFile is required when authorization is enabled with replica sets (see the security lesson) |
| Connection string | mongodb://root:secret@localhost:27017 | mongodb://localhost:27017/?replicaSet=rs0 |
A one-line docker run (standalone)
Section titled “A one-line docker run (standalone)”This command pulls the official image, starts a container named mongo-dev, publishes the default MongoDB port 27017, stores the data in a named volume so it survives a restart, and sets a root username and password:
docker run -d \ --name mongo-dev \ -p 27017:27017 \ -v mongo-data:/data/db \ -e MONGO_INITDB_ROOT_USERNAME=root \ -e MONGO_INITDB_ROOT_PASSWORD=secret \ mongo:8A few things worth understanding in that command:
-druns the container in the background (detached) so it does not tie up your terminal.-p 27017:27017maps the container’s port27017to the same port on your host, which is where MongoDB listens by default.-v mongo-data:/data/dbmounts a named volume at/data/db, the directory MongoDB writes its files to. The volume lives outside the container, so deleting and recreating the container does not lose your data.-e MONGO_INITDB_ROOT_USERNAME/-e MONGO_INITDB_ROOT_PASSWORDcreate an administrative user the first time the container initialises an empty data directory (documented on the image page, hub.docker.com/_/mongo).
Verify it is running
Section titled “Verify it is running”docker ps --format 'table {{.Image}}\t{{.Status}}\t{{.Ports}}\t{{.Names}}'IMAGE STATUS PORTS NAMESmongo:8 Up 8 seconds 0.0.0.0:27017->27017/tcp, [::]:27017->27017/tcp mongodb-b1-devThe mongosh client ships inside the image, so you can run it without installing anything. The ping goes through the container’s own shell:
docker exec -it mongo-dev mongosh "mongodb://root:secret@localhost:27017" --eval 'db.runCommand({ ping: 1 })'{ ok: 1 }mongosh on your host (if you installed it) takes the same string, pointed at the published port: mongosh "mongodb://root:secret@localhost:27017". A successful interactive connection drops you at a test> prompt.
What “auth is on” really means
Section titled “What “auth is on” really means”The root variables switch authentication on. We proved the exact behaviour against this container, with no credentials in the connection string:
> db.runCommand({ ping: 1 }) # ping needs no login{ ok: 1 }> db.members.find().toArray()MongoServerError: Command find requires authentication (code 13, codeName Unauthorized)Without the two MONGO_INITDB_ROOT_* variables (and no --auth), the same image starts with no authentication at all and binds every interface: getCmdLineOpts reports net: { bindIp: '*' }, and an unauthenticated insertOne/find just works. That is convenient on a laptop and dangerous on any machine whose port 27017 is reachable from outside.
A docker-compose file (standalone)
Section titled “A docker-compose file (standalone)”For anything beyond a quick test, a docker-compose.yml is nicer: the configuration lives in a file you can commit, and a single command brings everything up. This version runs MongoDB and adds mongo-express, a lightweight web UI for browsing your data. Two changes from a naive copy-paste, both proven: the UI image is pinned (latest is exactly what this lesson tells you not to use), and there is no container_name, so docker compose exec mongo ... finds the service by its name.
services: mongo: image: mongo:8 restart: unless-stopped ports: - "27017:27017" environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: secret volumes: - mongo-data:/data/db
mongo-express: image: mongo-express:1.0.2-20-alpine3.19 restart: unless-stopped ports: - "8081:8081" environment: ME_CONFIG_MONGODB_URL: "mongodb://root:secret@mongo:27017/" depends_on: - mongo
volumes: mongo-data:docker compose up -ddocker compose psdocker compose exec mongo mongosh "mongodb://root:secret@localhost:27017" --eval 'db.runCommand({ ping: 1 })'NAME IMAGE STATUS PORTSmongodb-b1-mongo-1 mongo:8 Up 15 seconds 0.0.0.0:27017->27017/tcp, [::]:27017->27017/tcpmongodb-b1-mongo-express-1 mongo-express:1.0.2-20-alpine3.19 Up 15 seconds 0.0.0.0:8081->8081/tcp, [::]:8081->8081/tcp{ ok: 1 }curl localhost:8081/ returned 401, and curl -u admin:pass localhost:8081/ returned 200: mongo-express protects itself with basic auth whose default is admin:pass (its own log prints basicAuth credentials are "admin:pass", it is recommended you change this). Set ME_CONFIG_BASICAUTH_USERNAME / ME_CONFIG_BASICAUTH_PASSWORD before you expose the port to anyone, or delete the mongo-express service - it is optional.
A one-node replica set (for transactions and change streams)
Section titled “A one-node replica set (for transactions and change streams)”A replica set needs two things a standalone does not: mongod started with --replSet <name>, and a one-time rs.initiate(...). The docker run version, step by step:
docker run -d --name mongo-rs -p 27017:27017 mongo:8 mongod --replSet rs0 --bind_ip_allsleep 5docker exec mongo-rs mongosh --quiet --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'Before rs.initiate the node refuses writes - we recorded it:
> db.members.insertOne({ a: 1 })NotWritablePrimary not primary> db.hello().isWritablePrimaryfalseAfter it, db.hello() reports setName: 'rs0' and isWritablePrimary: true, and a ping reply now carries cluster time ($clusterTime, operationTime) that a standalone does not send:
{ setName: 'rs0', isWritablePrimary: true, me: 'localhost:27017' }{ ok: 1, '$clusterTime': { clusterTime: Timestamp({ t: 1790747453, i: 18 }), ... }, operationTime: Timestamp({ t: 1790747453, i: 18 }) }For a file you can commit, let a healthcheck do the initiation, so docker compose up --wait returns only when the replica set is ready:
services: mongo: image: mongo:8 command: ["mongod", "--replSet", "rs0", "--bind_ip_all"] ports: - "27017:27017" volumes: - mongo-rs-data:/data/db healthcheck: test: > mongosh --quiet --eval "try { rs.status().ok } catch (e) { rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'localhost:27017' }] }).ok }" interval: 5s timeout: 10s retries: 10 start_period: 5s
volumes: mongo-rs-data:docker compose -f docker-compose.replset.yml up -d --wait printed Container ... Healthy and ps showed Up 5 seconds (healthy). The member host is localhost:27017, which is reachable from your machine because the published port is also 27017. From the host, this Node script (run against that container) completed a transaction and opened a change stream - both impossible on the standalone:
// app/connect-replset.mjs @skip-verify needs the lesson's own replica-set container on host port 27017import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb://localhost:27017/?replicaSet=rs0");await client.connect();console.log(Object.keys(await client.db("app").command({ ping: 1 }))); // [ 'ok', '$clusterTime', 'operationTime' ]
const session = client.startSession();await session.withTransaction(async () => { await client.db("app").collection("t").insertOne({ x: 1 }, { session });});console.log("txn ok");await client.close();How the pieces fit together
Section titled “How the pieces fit together”The container runs MongoDB and listens on port 27017. Your published port forwards localhost:27017 into it, the named volume keeps the data safe outside the container, and anything that speaks the MongoDB protocol - mongosh, Compass, or your application - connects through that port.
flowchart LR App["Your app / mongosh / Compass"] -->|"mongodb://localhost:27017"| Port["Host port 27017"] Port --> Container["mongo container"] Container -->|"writes to /data/db"| Volume["named volume: mongo-data"]
The two mongosh checks below run against the lesson harness (a one-node replica set), so both print what you would see on the replica-set variant:
const h = db.hello();console.log({ setName: h.setName, isWritablePrimary: h.isWritablePrimary, version: db.version() });assert.strictEqual(h.isWritablePrimary, true);assert.strictEqual(db.runCommand({ ping: 1 }).ok, 1);{ setName: 'rs0', isWritablePrimary: true, version: '8.3.11' }Tips and gotchas
Section titled “Tips and gotchas”- Data persistence lives in the volume, not the container. As long as you keep the
mongo-datavolume, you can stop, remove, and recreate the container without losing anything.docker volume rm mongo-datais what deletes the data. - The root user is created only on first init. We proved it: after
docker rm -fwe recreated the container on the same volume withMONGO_INITDB_ROOT_PASSWORD=other. Logging in with the old passwordsecretstill worked (authenticatedUsers: [ { user: 'root', db: 'admin' } ]), and the new password gaveMongoServerError: Authentication failed.The variables are ignored once the data directory is not empty. - Port
27017is the MongoDB default. If something else already uses it, change the host side of the mapping, for example-p 27018:27017, and connect to27018(see the replica-set callout above). - Pin a major version like
mongo:8rather thanmongo:latestso an image update does not surprise you with a different server version.mongo:8resolved to 8.3.11 on 2026-09-30; 8.3 is a rapid-release minor of the 8 major;mongo:8.0pins the 8.0 release series instead.
Reading AI-generated code
Section titled “Reading AI-generated code”Ask an assistant for “a MongoDB replica set in Docker for transactions” and you often get this compose file. It looks complete:
🐛 Spot the bug
# AI comment: "replica set enabled, so transactions and change streams will work"services:mongo: image: mongo:latest command: ["mongod", "--replSet", "rs0", "--bind_ip_all"] ports: - "27017:27017"Show the answer
--replSet rs0 only tells mongod which set it would belong to. Nobody ever calls rs.initiate, so the node has no config and no primary. Real result on mongo:8: db.members.insertOne({ a: 1 }) fails with NotWritablePrimary not primary, and db.hello().isWritablePrimary is false - every write and every transaction is refused, exactly the opposite of the comment’s promise. (It also pins latest.) The fix is the healthcheck initiation from the previous section, which we ran to Healthy, followed by a host-side transaction that committed:
services: mongo: image: mongo:8 command: ["mongod", "--replSet", "rs0", "--bind_ip_all"] ports: - "27017:27017" healthcheck: test: > mongosh --quiet --eval "try { rs.status().ok } catch (e) { rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'localhost:27017' }] }).ok }" interval: 5s timeout: 10s retries: 10 start_period: 5sMental Model
Section titled “Mental Model”A container is a process plus a directory: mongod running against /data/db. Everything else - the port mapping, the volume, the env vars - is wiring around those two things. The env vars are read once, by the image’s entrypoint, only when the directory is empty; after that, the directory (its users, its replica-set config) is the truth. That single idea explains the root-password surprise, the “replica set that was never initiated”, and why deleting the volume is the real reset button.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Run MongoDB via Docker | Fast to wipe and recreate, nothing permanent left on the machine, and an environment close to production | Requires a running Docker engine at all times, and a container adds a little overhead compared to running directly on the host |
| Install MongoDB directly on the machine | No container layer in between; connect through localhost directly | Harder to reset or change versions, and machines on the team can end up with mismatched versions or config |
| Standalone container | Simplest; supports root-user auth out of the box | No transactions, no change streams (proven above) |
| One-node replica set | Transactions and change streams work; matches how production runs | Extra rs.initiate step, no auth without a keyfile, and the member-host/port-mapping pitfall |
Common Misconceptions
Section titled “Common Misconceptions”- “Use
mongo:latest, it is the newest.” - the nextdocker pullcan change the server version under you. Pinmongo:8(or8.0). - “Removing the container deletes the data.” - with a named volume it does not; conversely, with no volume at all the data disappears with the container.
docker rmanddocker volume rmare different operations. - “
MONGO_INITDB_ROOT_PASSWORDresets the password.” - it does not; it only applies to an empty data directory (proven above). - “
--replSetmakes it a replica set.” - it needsrs.initiatetoo, otherwise writes fail withNotWritablePrimary. - “It is a dev machine nobody touches, so no password.” - without the root variables the image binds
*and requires no login; anything that can reach port 27017 can read and write.
Python/Go/Rust are not used in this lesson: it is Docker and shell only.