Recipe: MongoDB
Auth, a named volume, and a healthcheck.
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
services:mongo:image: mongo:7environment:MONGO_INITDB_ROOT_USERNAME: rootMONGO_INITDB_ROOT_PASSWORD_FILE: /run/secrets/mongo_pwMONGO_INITDB_DATABASE: paymentsAPP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD before compose up}secrets:- mongo_pwvolumes:- mongodata:/data/db- ./initdb:/docker-entrypoint-initdb.d:rohealthcheck:test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]interval: 10stimeout: 5sretries: 5volumes: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.
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.
mkdir -p secrets initdbopenssl rand -base64 24 > secrets/mongo_pw.txtexport APP_DB_PASSWORD="$(openssl rand -base64 24)"docker compose up -ddocker compose ps
[+] Running 3/3✔ Network payments_default Created✔ Volume payments_mongodata Created✔ Container payments-mongo-1 StartedNAME IMAGE STATUS PORTSpayments-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.
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()'
{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.
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.
$ 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.
mongosh -u app -p ... --authenticationDatabase payments payments. What is --authenticationDatabase payments telling mongosh?payments argument at the end of the line, not of this flag.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?