CoursesDocker in depthRecipe: MongoDB

Recipe: MongoDB

Auth, a named volume, and a healthcheck.

Intermediate10 min · lesson 25 of 30

For years, MongoDB started the way a shop opens with the door propped and the till left on the counter. It listened on the network and asked nobody for a password. Bots noticed. They scanned the internet, connected to whatever answered on port 27017, and deleted what they found. Plenty of real companies lost real data that way. The official mongo image gives you everything you need to shut that door, but the locks only work if you turn them. The alarm is installed and wired. Nobody arms it for you.

Three settings do the heavy lifting. Switch on authentication, meaning the server demands a username and password, from the very first startup. Put the data on a named volume so it survives a restart. Leave the port unpublished so nothing outside your Compose network can reach the database at all. Here is the whole setup, and then you go and test it.

The compose file

compose.yaml
services:
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD_FILE: /run/secrets/mongo_pw
MONGO_INITDB_DATABASE: payments
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD before compose up}
secrets:
- mongo_pw
volumes:
- mongodata:/data/db
- ./initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mongodata: {}
secrets:
mongo_pw:
file: ./secrets/mongo_pw.txt

Several small things in there are doing real work. Start with MONGO_INITDB_ROOT_PASSWORD_FILE. That _FILE ending is a convention the official image recognises. Instead of reading the password out of an environment variable, where it sits in plain view in docker inspect and in the process list, the image reads the value from a file on disk. That file is a Docker secret, mounted inside the container at /run/secrets/mongo_pw. Now look for a ports: block. There isn't one, and that is deliberate. The database answers only to other services on the same Compose network, never to your laptop and never to the public internet. When your application container wants the database, it dials the hostname mongo on port 27017. Compose runs a small internal DNS server (Domain Name System, the same lookup service that turns a website name into an address), so the service name finds the right container with no work from you. Last, the healthcheck sends a ping through mongosh, the current MongoDB shell that replaced the older mongo binary. Ping is one of a small handful of commands the server answers before you log in, so the check keeps passing once authentication is on.

A user for the app, not root

The root account is the master key to the whole building. It can create users, drop databases, and read every record on the server. Your application needs none of that reach. It reads and writes one database, and that is the entire job. So hand it a key cut for one door. If that credential ever leaks, in a log file, a screenshot, a laptop left on a train, the damage stops at a single database instead of the whole instance. The idea has a name, least privilege: give out the smallest set of permissions that still lets the work happen. Mongo gives you a tidy place to set it up. Any .js or .sh file you drop into /docker-entrypoint-initdb.d runs once, in filename order, the first time the container starts against an empty data directory.

initdb/01-app-user.js
db = db.getSiblingDB("payments");
db.createUser({
user: "app",
pwd: process.env.APP_DB_PASSWORD,
roles: [{ role: "readWrite", db: "payments" }]
});

APP_DB_PASSWORD arrives here as an ordinary environment variable, to keep the example short. In production you would give it the same _FILE treatment as the root password, for the reason above: plain environment variables surface in places you would rather they didn't. The init directory itself is a shared habit across the official database images. Postgres and MySQL read from the same kind of folder. Use it for schema, seed rows, or user creation. One catch is worth saying twice, because it catches people out: these scripts run only when the volume is empty. On a volume that already holds data they are skipped in silence. Treat them as first-run bootstrap, not as a migration tool.

terminal
mkdir -p secrets initdb
openssl rand -base64 24 > secrets/mongo_pw.txt
export APP_DB_PASSWORD="$(openssl rand -base64 24)"
docker compose up -d
docker compose ps
output
[+] Running 3/3
✔ Network payments_default Created
✔ Volume payments_mongodata Created
✔ Container payments-mongo-1 Started
NAME IMAGE STATUS PORTS
payments-mongo-1 mongo:7 Up 25 seconds (healthy)

Prove it works

A green healthcheck tells you the server is answering. It says nothing about whether anyone can log in. A light on the dashboard is not the same as fuel in the tank, so open the cap and look. Connect as the app user you created a minute ago, write a document, read it back. If authentication really is on and the scoped user really exists, this works first time. If you fumbled something, it fails right here on your laptop, loudly, instead of quietly in production at three in the morning.

terminal
docker compose exec mongo mongosh \
-u app -p "$APP_DB_PASSWORD" \
--authenticationDatabase payments payments \
--quiet \
--eval 'db.orders.insertOne({ id: 1, amount: 4200, status: "paid" })' \
--eval 'db.orders.find()'
output
{
acknowledged: true,
insertedId: ObjectId('66b9d4e2a1c3f45678901234')
}
[
{
_id: ObjectId('66b9d4e2a1c3f45678901234'),
id: 1,
amount: 4200,
status: 'paid'
}
]

One flag in that command trips people constantly: --authenticationDatabase payments. A Mongo user belongs to the database it was created in, the way a library card belongs to the branch that issued it. You present it at that branch. Your root user lives in admin. The app user lives in payments. So payments is where you point the flag. Aim it at admin and the app login fails with an authentication error, even when the password is character for character correct.

Restart the container and run the same find again. The document is still sitting there, because /data/db lives on the mongodata volume rather than in the container's writable layer. That difference is the whole point. The writable layer dies with the container. The volume does not. Destroy the container and keep the volume, and your data outlives it. Destroy the volume and the data is gone for good, with no undo anywhere. That named volume, not the container, is what your backup job has to point at.

Diagram
1docker compose up
first start, empty volume
2Root user created
from MONGO_INITDB_ROOT_* + secret file
3initdb scripts run
01-app-user.js makes the scoped app user
4Healthcheck: ping OK
container marked healthy
5App connects as 'app'
readWrite on payments, nothing else
6Restart later
volume has data, init steps are skipped
Those variables fire once, on an empty volume
MONGO_INITDB_ROOT_USERNAME, the root password, and every script in /docker-entrypoint-initdb.d run exactly once: on the first start against an empty /data/db. Bolt authentication onto a container that has already been running against a populated volume and nothing at all happens. It boots, reports healthy, and stays wide open, because Mongo sees existing data and skips initialization completely. There is no way to switch authentication on later by editing compose. The fix is a migration. You dump the data out, bring up a fresh instance with credentials in place from the first byte, and restore into that.

What to check on someone else's Mongo container

When you inherit a Mongo container from a teammate, three answers tell you most of what you need to know. Is authentication actually on? Does /data/db sit on a named volume? Does the healthcheck prove readiness rather than mere existence? Then look at the accounts, because root and the application must not be the same user. And if a host port is published, ask why. The only good answer is break-glass administration, and even then the port should be closed again when the emergency ends.

Size the WiredTiger cache to the container, not to the host. WiredTiger is the storage engine underneath MongoDB, the part that decides what stays in memory and what goes to disk, and left alone it sizes itself from what it can see of the machine. Put a memory limit on the container and Mongo will still reach for more than it is allowed, and the kernel will kill it. Running replica sets (several Mongo servers each keeping a copy of the same data) inside containers is fine in a lab. In production the placement rules and persistent storage stop being a side quest very fast, which is why a managed cluster is often the honest answer.

Back up with mongodump, or with filesystem snapshots your storage driver can take consistently. Then restore one into a scratch container and read a document back out of it. A backup nobody has ever restored is a guess. Rotate passwords inside the running database and update the secret file the application reads. Recreating the container will not rotate anything for you, because those initdb variables only apply to an empty volume.

Keep a short written record when you do this for real. Which host you ran compose up on, the mongo:7 digest you actually pulled, whether the volume was empty at that moment, and what you would do to back the change out. That last one carries more weight here than in most recipes, because the undo for a botched Mongo bootstrap is a dump and a restore, not a config edit. If a colleague cannot repeat your steps from the ticket alone, including the exact STATUS line you saw on a healthy container, the runbook is not finished yet.

Try this

Run this on a lab engine, Docker 24 or newer. It is the same idea as the compose file expressed as raw docker commands, and the sample output shows you what a healthy result looks like before you rely on it anywhere that matters.

terminal
$ docker volume create mongodata
$ docker run -d --name mongo --network appnet -v mongodata:/data/db -e MONGO_INITDB_ROOT_USERNAME=root -e MONGO_INITDB_ROOT_PASSWORD_FILE=/run/secrets/mongo_root --health-cmd='mongosh --eval "db.adminCommand('ping')" --quiet' mongo:7
$ docker inspect mongo --format '{{.State.Health.Status}}'
healthy
# STATUS: READY

Takeaway

The moment that decides whether this Mongo container is safe is its very first boot against an empty volume. Get the root secret, the scoped app user and the missing ports: block right at that moment, and everything after it is ordinary maintenance. Miss it, and your only route back is a dump and a restore.

Quick check
01A Mongo container has been running happily for a week against a volume full of data. You add MONGO_INITDB_ROOT_USERNAME and a root password to its compose file and recreate the container. What actually happens?
Incorrect — No. The initdb variables and scripts fire only when /data/db is empty. A volume that already holds data is left exactly as it was.
Correct — Yes. Those settings are first-run bootstrap only. Mongo spots the existing data, skips initialization, and never turns authentication on.
Incorrect — No, and that is exactly the trap. It starts cleanly and reports healthy while sitting there completely unauthenticated.
Incorrect — No, your data is safe. The problem runs the other way: the login you thought you added never activates.
02The verify step connects with mongosh -u app -p ... --authenticationDatabase payments payments. What is --authenticationDatabase payments telling mongosh?
Incorrect — That is the job of the positional payments argument at the end of the line, not of this flag.
Incorrect — The database already exists thanks to MONGO_INITDB_DATABASE. This flag is about where the credentials live, not about creating anything.
Correct — The app user was created in payments, so you present the login at payments. Point the flag at admin and it fails even with the right password.
Incorrect — Where a login gets checked and whether the traffic is encrypted are two separate settings. This flag only handles the first.
03Your Mongo container reports Up (healthy), yet the application keeps bouncing off it with an authentication error. Given the healthcheck in this compose file, why did it not catch that?
Correct — A green ping says the process is up and replying. It never touches the app user's credentials.
Incorrect — A healthcheck runs whatever command you hand it. This one happens to use ping, which needs no login.
Incorrect — This healthcheck sets no start_period, and start_period would not turn a failing check green in any case.
Incorrect — mongosh does not carry logins between healthcheck runs. The ping never authenticates in the first place.

Related