Mert Özen Further With Every Line
Database MongoDB — #02

MongoDB Lab: a Single-Node Replica Set in Docker, mongosh, Compass and the Bookstore Data

Mert Özen Nov 14, 2026 14 min 7 views
MongoDB Lab: a Single-Node Replica Set in Docker, mongosh, Compass and the Bookstore Data

The right MongoDB setup for development is a password-protected single-node replica set listening on 127.0.0.1; transactions and change streams only work on a replica set, and the member name must resolve from the host. With real output: the compose file and keyfile, rs.initiate, users and roles, mongosh, loading the Bookstore data with mongoimport, connecting from .NET and the transaction error on a standalone server.

Version note: The examples in this post were run with MongoDB 9.0.8 Community Server (mongodb/mongodb-community-server:9.0-ubuntu2204), mongosh 2.6, MongoDB Database Tools 100.20 (mongoimport), .NET 10 and MongoDB.Driver 3.14. The same compose file gave the same result on MongoDB 8.0.38 with the 8.0-ubuntu2204 tag.

Guess First

A teammate has started MongoDB in Docker with --replSet rs0 for Bookstore, the online bookstore used as the shared example across this site's series, and set up the replica set with the shortest command found in tutorials, a bare rs.initiate():

rs.initiate()
{
  info2: 'no configuration specified. Using a default configuration for the set',
  me: '296a64f2e716:27017',
  ok: 1
}

Port 27017 of the container is published to the host, and inside the container rs.status() shows a primary. Now the .NET application connects from the Windows machine, and the connection string includes the replica set name: mongodb://...@localhost:27017/?replicaSet=rs0&authSource=admin. Does it connect?

TimeoutException after 5021 ms
A timeout occurred after 5001ms selecting a server using CompositeServerSelector{ ... }.
Client view of cluster state is { ClusterId : "1", Type : "ReplicaSet", State : "Disconnected",
Servers : [{ ServerId: "{ ClusterId : 1, EndPoint : "Unspecified/296a64f2e716:27017" }",
EndPoint: "Unspecified/296a64f2e716:27017", ReasonChanged: "Heartbeat", State: "Disconnected", ...
HeartbeatException: "MongoDB.Driver.MongoConnectionException: An exception occurred while opening
a connection to the server. ---> System.Net.Sockets.SocketException ...

It failed, and the error mentions an address we never typed: 296a64f2e716:27017. The driver reached localhost:27017, but a client connecting to a replica set does not just use the address you give it; it learns the replica set's member list from the server and talks to those addresses from then on. A bare rs.initiate() used the container's hostname, the container ID, as the member name. That name resolves inside the container but not on the host. In this post we build the lab without falling into this and similar traps: the compose file, the keyfile, the replica set, users, mongosh, loading the Bookstore data and connecting from .NET.

Why a Single-Node Replica Set?

A single mongod looks like enough on a development machine, but a standalone server does not run some MongoDB features at all. Multi-document transactions and change streams exist only on a replica set or a sharded cluster. Let's try to start a transaction on a standalone server:

// 02 - multi-document transaction: needs a replica set
const session = db.getMongo().startSession();
const orders = session.getDatabase("bookstore").getCollection("tx_demo");
try {
    session.startTransaction();
    orders.insertOne({ _id: 1, note: "inside a transaction" });
    session.commitTransaction();
    print("committed, documents:", db.getSiblingDB("bookstore").tx_demo.countDocuments());
} catch (e) {
    print(e.name + ":", e.message);
} finally {
    db.getSiblingDB("bookstore").tx_demo.drop();
    session.endSession();
}
MongoServerError: Transaction numbers are only allowed on a replica set member or mongos

The same script prints committed, documents: 1 on the lab we build below. If your application code will use transactions or change streams later (we use both in post 10), the development environment should have the same topology as production. A single-node replica set is the cheapest way to get there: one container, one volume, but the server sees itself as a replica set member, keeps an oplog and satisfies the majority write concern with its single member. It gives no high availability; that needs at least three members and is the topic of post 13.

compose.yaml: Image, Keyfile and Auth

The whole lab lives in one compose file in the repo:

name: bookstore-mongo

services:
  mongo:
    image: mongodb/mongodb-community-server:9.0-ubuntu2204
    environment:
      MONGODB_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:?set MONGO_ROOT_USER in .env}
      MONGODB_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?set MONGO_ROOT_PASSWORD in .env}
    # The keyfile is created inside the container, so file permissions do not depend on the host OS
    entrypoint:
      - bash
      - -c
      - |
        if [ ! -f /data/configdb/keyfile ]; then
          openssl rand -base64 756 > /data/configdb/keyfile
          chmod 400 /data/configdb/keyfile
        fi
        exec python3 /usr/local/bin/docker-entrypoint.py "$$@"
      - mongo-entrypoint
    command: ["mongod", "--replSet", "rs0", "--keyFile", "/data/configdb/keyfile"]
    ports:
      - "127.0.0.1:27017:27017"
    volumes:
      - mongo-data:/data/db
      - mongo-config:/data/configdb
      - ./data:/lab/data:ro
      - ./scripts:/lab/scripts:ro

volumes:
  mongo-data:
  mongo-config:

The image is mongodb/mongodb-community-server, published by MongoDB itself. The 9.0-ubuntu2204 tag pins two things: the latest 9.0 patch and the Ubuntu 22.04 base. The image is published for both amd64 and arm64; on an x64 machine MongoDB has required a CPU with AVX support since 5.0. On an old CPU, or in a virtual machine that does not expose AVX, mongod does not start. Next to mongod, the image contains mongosh and the Database Tools such as mongoimport and mongodump, so you can do everything with docker compose exec without installing anything else on Windows.

When MONGODB_INITDB_ROOT_USERNAME and MONGODB_INITDB_ROOT_PASSWORD are set and the data directory is empty, the entrypoint first starts mongod temporarily on 127.0.0.1 only and without a replica set, creates a user with the root role in the admin database, then stops the server and starts it again with the real command and --auth. This step only runs on the first start; once the volume has data, changing the variables has no effect. The values come from a .env file that is not committed; the repo only has .env.example, and the ${...:?...} syntax stops compose with an error message when a value is missing.

The keyfile is needed because once auth is on in a replica set, members must also authenticate each other, and the simplest method is a shared keyfile. Even in a one-member set mongod will not start without one (the error is in the pitfalls section). According to the documentation, on Unix systems the keyfile must not be readable by group or others. If you bind mount the file from Windows, its permissions come from the host's file system and may not follow that rule. So we wrap the entrypoint in a small bash script that generates the keyfile with openssl inside the container, in its own volume, and gives it chmod 400. The $$@ in the compose file is an escape so that compose does not treat $ as its own variable; it reaches the container as $@.

The port line is 127.0.0.1:27017:27017. Had we written only 27017:27017, Docker would publish the port on every network interface of the machine; why that matters is in the pitfalls section. The last two volumes mount the repo's data and scripts folders into the container read-only; we run the data files and the posts' scripts from /lab/....

First Start and rs.initiate

# then set your own password in .env
Copy-Item .env.example .env
docker compose up -d
docker compose exec mongo mongosh -u admin --authenticationDatabase admin -p

When -p is the last option and has no value, mongosh asks for the password without echoing it; the password does not end up in the command history. Once connected, we start the replica set with an explicit member name:

// 02 - single-node replica set: member name localhost:27017 also resolves from the host
const result = rs.initiate({
    _id: "rs0",
    members: [{ _id: 0, host: "localhost:27017" }]
});
printjson(result);
{
  ok: 1
}
rs.status().members.map(m => m.name + " " + m.stateStr)
db.adminCommand({ getCmdLineOpts: 1 }).parsed
[ 'localhost:27017 PRIMARY' ]
{
  net: {
    bindIp: '*'
  },
  replication: {
    replSet: 'rs0'
  },
  security: {
    authorization: 'enabled',
    keyFile: '/data/configdb/keyfile'
  }
}

The name localhost:27017 points to the right place on both sides: inside the container it is mongod itself, on the host it is the published port. That is why the problem from "Guess First" does not happen here. In a set that several machines or containers connect to, localhost would be wrong; then you need a DNS name everyone can resolve. The server's runtime options are as expected: replica set name rs0, authorization enabled, keyfile in place. bindIp: '*' shows that it listens on every interface inside the container; what limits access from outside is the 127.0.0.1 port mapping in compose.

When you connect as an admin, mongosh also shows the server's startup warnings. This lab showed four: a recommendation to use the XFS file system with WiredTiger, two sysfs settings related to the memory allocator and a recommendation to set swappiness to 0 or 1. All of them are settings of the Linux virtual machine Docker runs in; they do not matter for the lab, but on a production server they should be set according to MongoDB's production notes.

Users and Roles

The admin user has the root role: it reads every database, creates users and shuts the server down. An application connecting with this user means a flaw in the application spreads to the whole server. For the application we create a separate user that can only read and write in the bookstore database:

// 02 - application user: read/write on the bookstore database only
const bookstore = db.getSiblingDB("bookstore");
bookstore.createUser({
    user: "bookstore_app",
    pwd: process.env.BOOKSTORE_APP_PASSWORD || passwordPrompt(),
    roles: [{ role: "readWrite", db: "bookstore" }]
});
printjson(bookstore.getUser("bookstore_app", { showPrivileges: false }));
docker compose exec mongo mongosh -u admin --authenticationDatabase admin --quiet /lab/scripts/02-users.js -p
{
  _id: 'bookstore.bookstore_app',
  userId: UUID('721d10aa-3ef8-4791-9c71-b833a70e9a80'),
  user: 'bookstore_app',
  db: 'bookstore',
  roles: [
    {
      role: 'readWrite',
      db: 'bookstore'
    }
  ],
  mechanisms: [
    'SCRAM-SHA-1',
    'SCRAM-SHA-256'
  ]
}

passwordPrompt() reads the password from the terminal without echoing it; it is never written to the script or the command history. The environment variable option is only for automation. The user is defined in the bookstore database, so you must pass authSource=bookstore when connecting. The password is not stored on the server as plain text but as a SCRAM verifier. Do the roles work as expected? Three attempts: connecting without authentication, reading the admin database as the application user and writing to another database.

MongoServerError: Command listDatabases requires authentication
MongoServerError: not authorized on admin to execute command { find: "system.users", ... }
MongoServerError: not authorized on other to execute command { insert: "x", ... }

All three were rejected. Roles in detail, custom roles and the principle of least privilege are in post 14. One note: readWrite includes creating indexes, but not administrative commands such as collMod, which changes a collection's validation rules; that is why we set up validation as admin in post 5.

Everyday mongosh Commands

mongosh is a JavaScript REPL running on Node.js; it is the counterpart of sqlcmd on the SQL Server side, but its commands are JavaScript. Let's connect as the application user and look at the most common ones:

docker compose exec mongo mongosh "mongodb://bookstore_app@localhost:27017/bookstore?authSource=bookstore" -p
show dbs
show collections
db.books.countDocuments()
db.books.find({ language: "tr" }, { title: 1 })
load("/lab/scripts/02-check.js")

show dbs shows the application user only bookstore, the database it has rights on. db is the currently selected database; use bookstore changes it. find returns a cursor, mongosh prints the first 20 documents, and typing it fetches the next 20. For longer work it is better to put commands in a .js file and run them with load() or with mongosh file.js; every example in this series lives that way in the repo's scripts folder. Inside a script results are not printed automatically; you need print and printjson. mongosh's command history is kept in the container's HOME folder, which is the /data/db volume; it survives the container as long as the volume exists.

Moving the Bookstore Data into MongoDB

The data is derived from the Bookstore schema we used in the SQL Temelleri series: 13 categories, 69 authors, 77 books, 40 customers, 379 orders and 181 reviews. We did not copy it row by row; we shaped the tables into what the order screen, the book page and the review list read. Order items and payments sit inside the order, the book's author and category summary sit inside the book, and stock sits in the book's stock subdocument. Reviews are in a separate collection. The reasoning behind these decisions is the topic of post 4; here we load the result. Books, customers and orders keep their integer keys from SQL as _id, so we can put results next to the queries from SQL Temelleri. Reviews have an ObjectId _id; we generated the values deterministically from the review time and the SQL review_id, so they come out the same in your lab. In each file one line is one document, and the types are written as Extended JSON:

{"_id":26,"isbn":"9798900000268","title":"Dune","authors":[{"authorId":18,"name":"Frank Herbert"}],"category":{"categoryId":3,"name":"Science Fiction"},"language":"en","publishedYear":1965,"pageCount":513,"listPrice":{"$numberDecimal":"390.50"},"listedOn":{"$date":"2024-06-01T00:00:00Z"},"isActive":true,"stock":{"onHand":51,"reorderLevel":5,"updatedAt":{"$date":"2026-06-14T17:53:00Z"}}}

$numberDecimal becomes a Decimal128 and $date a BSON date; plain whole numbers become int. Fields that were NULL in SQL were not written at all: a book with no subtitle has no subtitle field. We see what that means for queries in post 3. A small script loads everything as the application user, dropping each collection first:

#!/usr/bin/env bash
# 02 - loads the Bookstore data as bookstore_app (each collection is dropped first)
set -euo pipefail
if [ -z "${BOOKSTORE_APP_PASSWORD:-}" ]; then
    read -r -s -p "bookstore_app password: " BOOKSTORE_APP_PASSWORD
    echo
fi
for collection in categories authors books customers orders reviews; do
    mongoimport --uri "mongodb://bookstore_app@localhost:27017/bookstore?authSource=bookstore" \
        --password "$BOOKSTORE_APP_PASSWORD" \
        --collection "$collection" --drop --file "/lab/data/$collection.json" 2>&1 | grep -v "connected to"
done
docker compose exec mongo bash /lab/scripts/02-load.sh
2026-11-14T12:44:15.252+0000	dropping: `bookstore.categories`
2026-11-14T12:44:15.258+0000	13 document(s) imported successfully. 0 document(s) failed to import.
2026-11-14T12:44:15.267+0000	dropping: `bookstore.authors`
2026-11-14T12:44:15.274+0000	69 document(s) imported successfully. 0 document(s) failed to import.
2026-11-14T12:44:15.282+0000	dropping: `bookstore.books`
2026-11-14T12:44:15.289+0000	77 document(s) imported successfully. 0 document(s) failed to import.
2026-11-14T12:44:15.296+0000	dropping: `bookstore.customers`
2026-11-14T12:44:15.302+0000	40 document(s) imported successfully. 0 document(s) failed to import.
2026-11-14T12:44:15.308+0000	dropping: `bookstore.orders`
2026-11-14T12:44:15.324+0000	379 document(s) imported successfully. 0 document(s) failed to import.
2026-11-14T12:44:15.330+0000	dropping: `bookstore.reviews`
2026-11-14T12:44:15.338+0000	181 document(s) imported successfully. 0 document(s) failed to import.

The script is idempotent: to return the lab to a clean state after the experiments in later posts, just run the same command again. Now let's check the counts and whether the types arrived correctly:

// 02 - load check: document counts and BSON types
for (const name of ["categories", "authors", "books", "customers", "orders", "reviews"]) {
    print(name.padEnd(11), db.getCollection(name).countDocuments());
}
printjson(db.books.findOne({ _id: 26 }));
printjson(db.orders.aggregate([
    { $match: { _id: 1 } },
    { $project: {
        orderedAt: { $type: "$orderedAt" },
        total: { $type: "$total" },
        quantity: { $type: { $first: "$items.quantity" } },
        items: { $size: "$items" }
    } }
]).toArray());
categories  13
authors     69
books       77
customers   40
orders      379
reviews     181
{
  _id: 26,
  isbn: '9798900000268',
  title: 'Dune',
  authors: [
    {
      authorId: 18,
      name: 'Frank Herbert'
    }
  ],
  category: {
    categoryId: 3,
    name: 'Science Fiction'
  },
  language: 'en',
  publishedYear: 1965,
  pageCount: 513,
  listPrice: Decimal128('390.50'),
  listedOn: ISODate('2024-06-01T00:00:00.000Z'),
  isActive: true,
  stock: {
    onHand: 51,
    reorderLevel: 5,
    updatedAt: ISODate('2026-06-14T17:53:00.000Z')
  }
}
[
  {
    _id: 1,
    orderedAt: 'date',
    total: 'decimal',
    quantity: 'int',
    items: 2
  }
]

The counts match the tables in SQL Temelleri, the date is a date and the money a decimal. Order 1's two items are inside the order. Do not skip this check when you move your own data; if you give mongoimport plain JSON instead of Extended JSON, it loads prices as doubles and dates as strings without any error, and you run into the problem from post 1.

Compass, VS Code, the Atlas CLI and .NET

If you want a graphical tool, MongoDB Compass is the official desktop application that runs on Windows: handy for browsing documents, building queries and aggregation pipelines step by step, and looking at indexes and explain plans. It connects to the lab with this connection string and asks for the password: mongodb://bookstore_app@localhost:27017/?authSource=bookstore&replicaSet=rs0. Because the member name is localhost:27017, it can connect in replica set mode as well. If you use VS Code, the "MongoDB for VS Code" extension lets you browse collections with the same connection and run queries in mongosh syntax in .mongodb.js files called playgrounds. I did not run these two tools for this lab; every output in the posts comes from mongosh.

Another option is the Atlas CLI's local deployment. According to the documentation, the atlas local setup command builds a single-node replica set in Docker with the mongodb/mongodb-atlas-local image, including MongoDB Search and Vector Search; the older atlas deployments commands are deprecated. The documentation also lists an Atlas account among the prerequisites. Since this series does not use an Atlas account, we did not run this path; if you do not need Search, the compose file above is both more transparent and closer to a Community or Enterprise setup in production.

Finally, .NET: the ping command in the lab project creates a MongoClient, sends the ping command, counts the books collection and prints the topology the driver sees. The connection string comes from an environment variable; replace the password with your own:

$env:BOOKSTORE_MONGO = "mongodb://bookstore_app:<password>@localhost:27017/bookstore?authSource=bookstore&replicaSet=rs0"
dotnet run --project src/Bookstore.MongoLab -- ping
ping ok in 76 ms, books = 77
  Unspecified/localhost:27017 -> ReplicaSetPrimary

The driver discovered the replica set and saw localhost:27017 as the primary. If you run into a member name like the one in "Guess First", there are two ways out: change the name in the replica set config to one the host can resolve with rs.reconfig(), or add directConnection=true to the connection string and turn off topology discovery. The second works on a single node, but with several members it also turns off failover, so it should not make it to production. Registering MongoClient as a singleton with DI, timeout settings and POCO mapping are in post 9.

In Production

This lab is a development environment. In production a replica set has at least three data-bearing members, each on a different machine and ideally in a different availability zone. For member authentication x.509 certificates are used instead of a keyfile, and client traffic uses TLS; both are in post 14.

Do not write passwords to files as plain text. In this lab .env is not committed, but in shared environments it is better to pass the password from a Docker secret file with the MONGODB_INITDB_ROOT_PASSWORD_FILE variable, which the image supports. The application's connection string should come from a secret store as well.

Treat the volume as data. docker compose down does not delete volumes; docker compose down -v does. That is the right command to reset the lab, but in production the volume needs backups, restore tests and capacity tracking. Backup options are in post 14.

Common Pitfalls

Exposing a container without auth on every interface. If no auth variables are given, the image starts mongod without authentication and it listens on every interface inside the container. If you also publish the port with -p 27017:27017, Docker opens it on every address of the machine:

docker run -d --name mongo-open -p 27017:27017 mongodb/mongodb-community-server:9.0-ubuntu2204
docker port mongo-open
27017/tcp -> 0.0.0.0:27017
27017/tcp -> [::]:27017

On a laptop connected to a café network, this means anyone on the same network can connect to the database without a password. In the past, MongoDB servers left open to the internet without a password were wiped in bulk and replaced with ransom notes for exactly this reason. As in the lab, turn auth on and bind the port to 127.0.0.1; docker compose port mongo 27017 should show 127.0.0.1:27017.

Turning on a replica set and auth without a keyfile. If you pass only --replSet rs0 together with the root user variables, the user creation step on first start runs, but the real mongod stops before it opens:

BadValue: security.keyFile is required when authorization is enabled with replica sets
try 'mongod --help' for more information

Trying a transaction on a standalone server. The Transaction numbers are only allowed on a replica set member or mongos error we saw above shows up the first time you run code that uses a transaction; that is why the lab is a replica set from the start. A member name the host cannot resolve, from a bare rs.initiate(), is the problem from "Guess First"; when you see that error, know that the replica set config is wrong, not the driver.

A Small Experiment

★☆☆ Stop the lab with docker compose down and start it again with docker compose up -d. Do you need to initiate the replica set again? Check with rs.status() and the book count, then try the same with down -v.

★★☆ Create a user called bookstore_report with read-only rights (the read role) and try to run 02-load.sh with it. Look at which step mongoimport stops at and with which error code.

★★★ Reproduce the situation from "Guess First" on your own machine: run rs.initiate() without arguments on a fresh volume, watch the ping command time out, then change the member name to localhost:27017 with rs.reconfig() and show that the same connection string now works.

The lab is ready and holds the Bookstore data. In the next post we move on to CRUD with this data: find filters and projection, update operators, upsert, the $elemMatch trap in array queries and decreasing stock without letting it go negative.