GuidesDevelopers and hostingMongoDB database server

Run a MongoDB database server with rootless Podman

MongoDB Community Server 9.0 on Debian 13 in rootless Podman under a user of its own, as a one-member replica set with a login per app, TLS for every connection, access only over WireGuard, a cache sized for 8 GB and nightly backups you have restored.

Tested on MongoDB Community Server 9.0.2 on Debian 13 (trixie) on a Melonslab server Updated October 14, 2026

Recommended server for this guide

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy · 2 vCPU · 8 GB Memory · 60 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€8.99/mo

Deploy now
On this page

What you will set up

MongoDB is a document database: it stores JSON-like documents instead of rows in tables, and many apps are built for it. This guide sets up MongoDB Community Server as your own database server, much like a managed one: a login per app that can only use its own database, TLS for every connection, a cache sized for the server, access only from this server and over WireGuard, and nightly backups.

MongoDB is developed by MongoDB, Inc., a company in the United States. The server is not open source. Since October 2018, it has been under the Server Side Public License (SSPL) version 1, which MongoDB wrote, and which the Open Source Initiative has not approved. You may run it for your own apps, commercial ones included, and change it. If you offer MongoDB itself as a service to others, the SSPL requires you to publish the source code of everything you use to run that service, including your management, monitoring and backup software. If that does not suit you, read the licence first, or use FerretDB, which is open source under Apache 2.0. The drivers, mongosh and the database tools are under the Apache 2.0 licence.

mongosh, MongoDB's shell, sends usage statistics to MongoDB by default. Step 4 switches that off.

Here MongoDB runs from mongo, the Docker Official Image, which Docker builds from MongoDB's own packages, in Podman under a user of its own called mongodb. It is not behind Caddy: a database is not a website, and only your apps should reach it.

Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:

  • MongoDB 9.0.2 started as a one-member replica set, and only accepted connections with TLS and a password. A client without TLS, a client that did not trust the certificate and a wrong password were refused.
  • The app's login could read, write and create indexes in its own database, but not read another database, create users or shut the server down.
  • A test app on another machine in the WireGuard network connected to 10.8.0.1 with Python and wrote and read documents. From the internet, the port gave no answer.
  • 100,000 documents were loaded, and transactions, change streams, aggregations, joins and text search worked.
  • A nightly dump was restored twice: one database under a new name, and everything, logins included, into an empty volume.
  • An upgrade from MongoDB 8.0 to 9.0, with the steps in step 11, went through on a separate test container. The server's certificate was renewed, and everything came back by itself after a reboot.

After a reboot, with the test data loaded, the whole server used 650 MB of memory, and 710 MB after queries over the 100,000 documents, of which MongoDB used 190 MB. Its cache takes more as it fills with your data, up to the 3 GB set in step 4.

Before you start

You need:

If your app runs on another Melonslab server, read Connect two servers first: two servers in the same network cannot reach each other directly. No DNS record is needed: apps connect to the tunnel address.

The examples use 203.0.113.10 for the database server's public address, 10.8.0.2 for the app server in the tunnel, and appdb and appuser for the app's database and login. Replace them with your own throughout.

1. Create the user

As root:

useradd -m -s /bin/bash mongodb
loginctl enable-linger mongodb
machinectl shell mongodb@

Everything up to step 6 runs as mongodb.

2. Make a certificate

Apps check that they are talking to your database with a certificate. A public certificate authority such as Let's Encrypt only issues certificates for public names, and apps reach this database on a private address, so you make your own small certificate authority, and give its certificate to your apps:

mkdir -p ~/tls && cd ~/tls
umask 077
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc -days 3650 -subj "/CN=MongoDB CA" -keyout ca.key -out ca.crt
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc -subj /CN=mongodb -keyout server.key -out server.csr
echo "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:10.8.0.1" > server.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -days 825 -extfile server.ext -out server.crt
chmod 644 ca.crt
cd
  • ca.crt is the certificate authority's own certificate. It is not secret: your apps get a copy.
  • ca.key signs certificates your apps will trust. Keep it safe; you could also move it off the server, and bring it back when the server's certificate is renewed.
  • server.crt and server.key are the database's certificate, valid for localhost, 127.0.0.1 and the tunnel address 10.8.0.1, for 825 days. Step 11 shows how to renew it.

3. Create the secrets

cat ~/tls/server.crt ~/tls/server.key | podman secret create mongodb-tls -
openssl rand -base64 756 | podman secret create mongodb-keyfile -
for u in admin appuser; do openssl rand -hex 24 | tr -d '\n' | podman secret create mongodb-$u-password -; done
  • mongodb-tls is the certificate and its key in one file, the way MongoDB wants them.
  • mongodb-keyfile is a shared key that the members of a replica set use to recognise each other. With one member it is not used for much, but MongoDB will not run a replica set with logins without it.
  • The last line makes the passwords for your own login, admin, and the app's, appuser.

Podman stores them, not the files below.

4. Describe the container

MongoDB's shell sends usage statistics to MongoDB by default. A small settings file, which the container will use, switches that off for good:

printf 'mongosh:\n  forceDisableTelemetry: true\n' > ~/mongosh.conf
chmod 644 ~/mongosh.conf
mkdir -p ~/.config/containers/systemd

Create ~/.config/containers/systemd/mongodb.container:

[Unit]
Description=MongoDB

[Container]
ContainerName=mongodb
Image=docker.io/library/mongo:9.0
Volume=mongodb-data:/data/db
# Reachable on this server and over WireGuard only, never on the public address
PublishPort=127.0.0.1:27017:27017
PublishPort=10.8.0.1:27017:27017
Environment=MONGO_INITDB_ROOT_USERNAME=admin MONGO_INITDB_ROOT_PASSWORD_FILE=/run/secrets/mongodb-admin-password
Secret=mongodb-admin-password
Secret=mongodb-keyfile,uid=999,gid=999,mode=0400
Secret=mongodb-tls,uid=999,gid=999,mode=0400
Volume=%h/tls/ca.crt:/etc/mongodb/ca.crt:ro
Volume=%h/mongosh.conf:/etc/mongosh.conf:ro
# A one-member replica set, TLS for every connection, and a 3 GB cache for 8 GB of memory
Exec=--replSet rs0 --keyFile /run/secrets/mongodb-keyfile --tlsMode requireTLS --tlsCertificateKeyFile /run/secrets/mongodb-tls --tlsCAFile /etc/mongodb/ca.crt --tlsAllowConnectionsWithoutCertificates --wiredTigerCacheSizeGB 3
AutoUpdate=registry

[Service]
Restart=always

[Install]
WantedBy=default.target

What the settings do:

  • MONGO_INITDB_ROOT_USERNAME and the password file make the image create the admin login, with full rights, the first time it starts. Without a login, MongoDB lets anyone who reaches it do anything.
  • --replSet rs0 makes MongoDB a replica set with one member. Transactions and change streams only work in a replica set, and many apps need them. A replica set also keeps a log of every change, the oplog, which makes the backups in step 10 consistent.
  • --tlsMode requireTLS refuses every connection without TLS, on this server too. --tlsAllowConnectionsWithoutCertificates means clients prove who they are with a password, not with a certificate of their own.
  • --wiredTigerCacheSizeGB 3 is MongoDB's own cache; step 6 explains the size.
  • uid=999 lets MongoDB, which runs as user 999 in its container, read its key and certificate, and mode=0400 keeps them from everyone else.
  • AutoUpdate=registry updates MongoDB within 9.0 (step 11).

Start it:

systemctl --user daemon-reload
systemctl --user start mongodb
systemctl --user enable --now podman-auto-update.timer

The first start downloads about 1.1 GB, and MongoDB takes a few seconds to create its first login. It is ready when this prints 1:

podman logs mongodb 2>&1 | grep -c 'init process complete'

5. Start the replica set and create the app's login

Add an alias for MongoDB's shell, mongosh, which runs it in the container:

echo "alias mongosh='podman exec -it mongodb mongosh --tls --tlsCAFile /etc/mongodb/ca.crt'" >> ~/.bashrc
source ~/.bashrc

Start the replica set. The secrets go to mongosh on its input, so they do not show in the list of processes:

printf 'db.getSiblingDB("admin").auth("admin", "%s"); rs.initiate({_id: "rs0", members: [{_id: 0, host: "localhost:27017"}]})\n' \
  "$(podman secret inspect --showsecret --format '{{.SecretData}}' mongodb-admin-password)" |
  podman exec -i mongodb mongosh --quiet --tls --tlsCAFile /etc/mongodb/ca.crt

The answer ends with ok: 1. The member's name is localhost:27017, the one name that stays right inside the container across restarts and updates. Clients outside connect straight to it with directConnection=true (step 9).

Then create the app's login, with the right to read and write its own database and nothing else:

printf 'db.getSiblingDB("admin").auth("admin", "%s"); db.getSiblingDB("appdb").createUser({user: "appuser", pwd: "%s", roles: [{role: "readWrite", db: "appdb"}]})\n' \
  "$(podman secret inspect --showsecret --format '{{.SecretData}}' mongodb-admin-password)" \
  "$(podman secret inspect --showsecret --format '{{.SecretData}}' mongodb-appuser-password)" |
  podman exec -i mongodb mongosh --quiet --tls --tlsCAFile /etc/mongodb/ca.crt

readWrite lets the app create collections and indexes in appdb, which its migrations need. We checked the rest with appuser:

read other db -> not authorized on shop to execute command { find: "orders", ...
createUser -> not authorized on appdb to execute command { createUser: "x", ...
shutdown -> not authorized on admin to execute command { shutdown: 1, ...

For another app, create a secret for its password as in step 3, and repeat the createUser command with its own name, database and secret. To log in yourself:

mongosh -u admin

It asks for the password, which podman secret inspect --showsecret --format '{{.SecretData}}' mongodb-admin-password shows. The prompt rs0 [direct: primary] test> means the replica set is running. exit leaves it.

6. Size it for the server

Go back to root with exit.

The cache. MongoDB keeps the data it uses in its own cache, WiredTiger's. By default it takes half of the memory minus 1 GB, about 3.4 GB here, but it cannot know what else the server runs. --wiredTigerCacheSizeGB 3 from step 4 sets it outright. The rest goes to connections, about 1 MB each, to other programs, and to the system's file cache, which MongoDB uses as well: its data files are compressed, and the file cache keeps them that way. On a server that only runs MongoDB, you can raise it to 3.5. db.serverStatus().wiredTiger.cache["maximum bytes configured"] in mongosh shows the value in use.

Huge pages. MongoDB 8.0 and later work best with the kernel's transparent huge pages on, and say so at every start with two warnings about /sys/kernel/mm/transparent_hugepage. Debian has them on already; two settings remain. Create /etc/tmpfiles.d/mongodb-thp.conf:

# Transparent huge pages as MongoDB 8.0 and later recommend
w /sys/kernel/mm/transparent_hugepage/defrag - - - - defer+madvise
w /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none - - - - 0

Apply it now:

systemd-tmpfiles --create /etc/tmpfiles.d/mongodb-thp.conf
cat /sys/kernel/mm/transparent_hugepage/defrag
always defer [defer+madvise] madvise never

MongoDB checks these when it starts, so restart it once, with systemctl --user restart mongodb as mongodb. systemd applies the file again at every boot, before MongoDB starts. After a reboot, our MongoDB started without warnings. It still notes in its log that it recommends the XFS file system; Debian uses ext4, which works.

7. Let your app servers in

MongoDB listens on the tunnel address 10.8.0.1, which exists only once WireGuard is up. If MongoDB starts first at boot, it fails with Cannot assign requested address. Let programs listen on an address before it exists:

echo "net.ipv4.ip_nonlocal_bind = 1" > /etc/sysctl.d/60-nonlocal-bind.conf
sysctl --system

Then open port 27017 on the tunnel only:

ufw allow in on wg0 to any port 27017 proto tcp
ss -tlnp | grep 27017
LISTEN 0      128         10.8.0.1:27017      0.0.0.0:*    users:(("pasta.avx2",pid=1161,fd=7))
LISTEN 0      128        127.0.0.1:27017      0.0.0.0:*    users:(("pasta.avx2",pid=1161,fd=6))

Only the tunnel address and 127.0.0.1: nothing listens on the public address.

8. Check what others see

From any computer that is not in the tunnel, try the public address:

nc -vz -w 10 203.0.113.10 27017
nc: connect to 203.0.113.10 port 27017 (tcp) timed out: Operation now in progress

Nothing listens there, and ufw drops the attempt without an answer. Where MongoDB does listen, it only takes TLS and a password. We tried with mongosh on the server without TLS, without the certificate authority and with a wrong password:

MongoServerSelectionError: connection <monitor> to 127.0.0.1:27017 closed
MongoNetworkError: self-signed certificate in certificate chain
MongoServerError: Authentication failed.

9. Connect from an app

Copy ~/tls/ca.crt from the database server to the app server, here to /opt/notes/ca.crt. The app's connection string is:

mongodb://appuser:PASSWORD@10.8.0.1:27017/appdb?tls=true&tlsCAFile=/opt/notes/ca.crt&directConnection=true
  • appdb is the database the app logs in to and uses by default. MongoDB creates it when the app first writes to it.
  • directConnection=true makes the driver talk to 10.8.0.1 only. Without it, the driver asks the replica set for its members, gets localhost:27017, and tries that on the app server instead. In our test, that failed with Could not reach any servers in [('localhost', 27017)].
  • The passwords from step 3 are hexadecimal. A password of your own with characters such as @, : or / must be percent-encoded in the string.

An example in Python, with PyMongo, MongoDB's own Python driver. On the app server, with Debian 13:

apt install -y python3-pymongo

Create /opt/notes/notes.py:

import datetime
import os

from pymongo import MongoClient

client = MongoClient(os.environ["MONGODB_URI"])
notes = client.get_default_database().notes

notes.insert_one({"text": "Hello from the app", "at": datetime.datetime.now(datetime.UTC)})
for note in notes.find().sort("at", -1).limit(3):
    print(note["text"])
print(notes.count_documents({}), "notes in total")
MONGODB_URI='mongodb://appuser:PASSWORD@10.8.0.1:27017/appdb?tls=true&tlsCAFile=/opt/notes/ca.crt&directConnection=true' python3 /opt/notes/notes.py
Hello from the app
1 notes in total

The same code runs unchanged against FerretDB; only the connection string differs. In a real app, keep the string in the app's own secrets, not in its code.

We also ran a set of tests over 100,000 documents, the same as in the FerretDB guide. On MongoDB 9.0 all of them worked, transactions and change streams included, except $where: MongoDB 9.0 refuses JavaScript in queries by default, with $where is not allowed in this context.

10. Back up

mongodump writes every database, the logins included, while MongoDB keeps working. With --oplog, it also saves the changes made while it runs, so the dump is a copy of one moment. As mongodb, create ~/bin/mongodb-backup:

mkdir -p ~/bin ~/.config/systemd/user
#!/bin/sh
# Dump every database with mongodump, and keep 14 days
set -e
umask 077
dir=$HOME/backup
day=$(date +%F)
mkdir -p "$dir"
podman exec mongodb sh -c '
  printf "password: %s\n" "$(cat /run/secrets/mongodb-admin-password)" |
  mongodump --quiet --config=/dev/stdin --oplog --gzip --archive \
    --uri="mongodb://admin@localhost:27017/?authSource=admin&tls=true&tlsCAFile=/etc/mongodb/ca.crt&directConnection=true"
' > "$dir/mongodump-$day.gz"
find "$dir" -type f -mtime +13 -delete

The password reaches mongodump on its input, not on its command line, and the dump comes out on its output, straight into the file. Run it every night with a service and a timer. Create ~/.config/systemd/user/mongodb-backup.service:

[Unit]
Description=Back up MongoDB

[Service]
Type=oneshot
ExecStart=%h/bin/mongodb-backup

And ~/.config/systemd/user/mongodb-backup.timer:

[Unit]
Description=Back up MongoDB every night

[Timer]
OnCalendar=*-*-* 02:30
Persistent=true

[Install]
WantedBy=timers.target

Switch it on, and run it once now:

chmod 755 ~/bin/mongodb-backup
systemctl --user daemon-reload
systemctl --user enable --now mongodb-backup.timer
systemctl --user start mongodb-backup.service
ls -l ~/backup
-rw------- 1 mongodb mongodb 2615655 Oct 14 02:13 mongodump-2026-10-14.gz

For our 100,000 test documents it took 2 seconds. Copy ~/backup off the server every night, for example with restic, and keep it private: it holds all your data and the logins' password hashes.

Test a restore. Restore one database under a new name, next to the real one:

podman exec -i mongodb mongorestore \
  --uri="mongodb://admin:ADMIN_PASSWORD@localhost:27017/?authSource=admin&tls=true&tlsCAFile=/etc/mongodb/ca.crt&directConnection=true" \
  --gzip --archive --nsInclude='appdb.*' --nsFrom='appdb.*' --nsTo='appdb_restore.*' \
  < ~/backup/mongodump-2026-10-14.gz
125010 document(s) restored successfully. 0 document(s) failed to restore.

Our appdb_restore had as many documents in each collection as appdb, and the same indexes. Remove it with db.getSiblingDB("appdb_restore").dropDatabase() in mongosh. The admin password is on the command line here, so run it when no one else is logged in to the server.

Restore everything, for example on a new server, with the same secrets. Start with an empty volume, start the replica set as in step 5, and restore the whole dump:

systemctl --user stop mongodb
podman volume rm mongodb-data
systemctl --user start mongodb

When podman logs mongodb 2>&1 | grep -c 'init process complete' prints 1 again, run the rs.initiate command from step 5, then:

podman exec -i mongodb mongorestore \
  --uri="mongodb://admin:ADMIN_PASSWORD@localhost:27017/?authSource=admin&tls=true&tlsCAFile=/etc/mongodb/ca.crt&directConnection=true" \
  --gzip --archive --oplogReplay < ~/backup/mongodump-2026-10-14.gz

Ours came back with every document and index, and appuser could log in with its old password. Leave out --drop here: with it, mongorestore removed the admin login it was using halfway through, and failed with requires authentication.

11. Update

Within 9.0, such as 9.0.2 to 9.0.3, podman auto-update checks once a day for a new image and restarts MongoDB when there is one. A restart takes a few seconds, and apps lose their connections for a moment. 9.0.2 was the newest when we tested, so we have not seen such an update. To update now, as mongodb:

podman auto-update

A new major version changes how data is stored, and MongoDB only upgrades from the one before: from 9.0 to the next, not past it. We tested the steps from 8.0 to 9.0. Take a backup first:

systemctl --user start mongodb-backup.service

Change the tag in mongodb.container to the new version, then restart:

systemctl --user daemon-reload
systemctl --user restart mongodb

The new version starts in compatibility mode, featureCompatibilityVersion of the old one, so you can still go back. When your apps work, switch it, in mongosh as admin, with the new version number:

db.adminCommand({setFeatureCompatibilityVersion: "9.0", confirm: true})

In our test, 8.0.34 to 9.0.2, the data was all there before and after the switch. Read MongoDB's upgrade notes for the version first: they list what changes, and anything you must do before.

Renew the certificate before it runs out, in about two years. openssl x509 -enddate -noout -in ~/tls/server.crt shows the date. Make a new one with the same authority, so apps need no change:

cd ~/tls
umask 077
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc -subj /CN=mongodb -keyout server.key -out server.csr
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -days 825 -extfile server.ext -out server.crt
cat server.crt server.key | podman secret create --replace mongodb-tls -
systemctl --user restart mongodb

We tested it once: the app connected again with its old ca.crt.

Troubleshooting

MongoDB does not start after a reboot, and its log says Failed to bind port 27017 (Cannot assign requested address). It started before WireGuard. Add the setting from step 7, then systemctl --user reset-failed and systemctl --user start mongodb as mongodb.

Could not reach any servers in [('localhost', 27017)]. The app connects as to a replica set and looks for localhost on its own server. Add directConnection=true to the connection string.

MongoServerSelectionError: connection <monitor> to ... closed. The client connected without TLS. Add tls=true and tlsCAFile to the connection string.

self-signed certificate in certificate chain. The client does not trust the certificate. Check that tlsCAFile points at the copy of ca.crt.

Authentication failed. for the app's login. The app logs in to another database than the one its login was made in. Its login belongs to appdb, so the connection string must end in /appdb, or have authSource=appdb.

not authorized on ... to execute command. The login has no rights there. An app login only has readWrite on its own database. Give it more with db.getSiblingDB("appdb").grantRolesToUser("appuser", [...]) as admin, if it really needs it.

Warnings about /sys/kernel/mm/transparent_hugepage when MongoDB starts. The file from step 6 is missing, or MongoDB started before it was applied: restart MongoDB.

An app server waits and times out. Check that WireGuard is up on both sides with wg show, and that the ufw rule from step 7 is there with ufw status.

Run it on your own server

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy

€8.99/mo

vCPU
2
Memory
8 GB
Storage
60 GB
Transfer
10 TB
Performance
SSD · RAID 1
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides