Skip to content

Self-host MongoDB

Your own MongoDB on a real disk, locked down, with backups you've restored.

Deploy on exe.dev

Shelley handles the setup. About 10 minutes.

Prefer a terminal?
curl -fsSL https://exe.dev/deploy/mongodb/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

MongoDB stores JSON-like documents, so apps keep their data in the shape their code already uses. Here it gets a VM of its own (or moves in next to your app), with a password on every connection, transactions and change streams switched on, and nightly backups you've seen restored. Nothing listens on the internet: your apps reach MongoDB on the VM, over SSH, or over your private Tailscale network.

What you get#

  • MongoDB 9.0 Community Edition, started at boot, listening on localhost only, with access control on.
  • A one-member replica set, so transactions and change streams work.
  • An admin user, and an app database with its own user and connection string in ~/mongodb/app.env.
  • Nightly backups with mongodump, one restored with mongorestore and checked.
What's running, and where
my-mongodb.exe.xyz exe.dev VM
MongoDB 9.0 Community Edition from MongoDB's apt repository, a one-member replica set :27017/var/lib/mongodb
Nightly backups mongodump of every database and user, 14 nights kept ~/mongodb/backups

Deploy with Shelley#

Click Deploy and Shelley will set up MongoDB on a new VM, then check that it works. No config-file wrangling required.

Prefer a terminal?
curl -fsSL \
  https://exe.dev/deploy/mongodb/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin

When it's done, run this on your computer (only you can):

ssh my-mongodb.exe.xyz cat mongodb/app.env
ssh -N -L 27018:localhost:27017 my-mongodb.exe.xyz
What Shelley is told
Set up MongoDB on this VM by following the exe.dev recipe at https://exe.dev/deploy/mongodb.md from top to bottom.

The recipe's example VM name is my-mongodb. This VM's name is the output of `hostname`: use it wherever the recipe writes my-mongodb. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- MongoDB 9.0 Community Edition, started at boot, listening on localhost only, with access control on.
- A one-member replica set, so transactions and change streams work.
- An `admin` user, and an `app` database with its own user and connection string in `~/mongodb/app.env`.
- Nightly backups with `mongodump`, one restored with `mongorestore` and checked.

Read the whole recipe, including the closed details. For initial setup,
follow "Install by hand" from top to bottom: the MongoDB repository and
server, the replica set and access control, log rotation, the app
database, the backup timer, and the scratch-server restore test. The
walkthrough sections are for the owner afterwards: don't import data,
set up Tailscale or Docker access, or run the Restore or Update
instructions during initial setup. Never overwrite an existing
~/mongodb/*.env file or /etc/mongod.keyfile. Leave the VM private and
do not reboot. Don't paste passwords into the chat; say they are in
~/mongodb/app.env and ~/mongodb/admin.env.

Finish with the URLs I should open and these commands, which only I can run, from my own computer (with this VM's name in them):
  ssh my-mongodb.exe.xyz cat mongodb/app.env
  ssh -N -L 27018:localhost:27017 my-mongodb.exe.xyz

Also at /deploy/mongodb/prompt.txt. The recipe it follows is /deploy/mongodb.md.

Connect from your computer#

Port 27017 isn't on the internet, so you bring it to your computer through SSH. exe.dev's SSH forwards ports like any SSH server. On your computer, print the app's connection settings, then open a tunnel and leave it running:

ssh my-mongodb.exe.xyz cat mongodb/app.env
ssh -N -L 27018:localhost:27017 my-mongodb.exe.xyz

Local port 27018 keeps out of the way of any MongoDB on your computer. In another terminal, connect with mongosh, using the MONGODB_URI from app.env with the port changed to 27018:

mongosh "mongodb://app:<password>@127.0.0.1:27018/app?directConnection=true"

Compass. MongoDB Compass is MongoDB's free desktop app for browsing documents, running queries and aggregations, and managing indexes. With the tunnel open, paste the same connection string into a new connection. Or let Compass open the tunnel itself: paste the string with port 27017, then under Advanced Connection Options, Proxy/SSH, choose SSH with Identity File, with SSH hostname my-mongodb.exe.xyz, port 22, username exedev, and your exe.dev SSH key (such as ~/.ssh/id_ed25519). Other desktop clients with an SSH option work the same way.

Keep directConnection=true in every connection string. Without it, drivers ask the replica set for its members, get 127.0.0.1:27017 back, and try to reach that from your computer.

For admin work, skip the tunnel: ssh -t my-mongodb.exe.xyz mongodb/mongosh.sh opens mongosh on the VM as the admin user.

Connect an app on this VM#

Your app's database is called app, with a MongoDB user of the same name that can read, write, and index anything in it and nothing outside it. Its settings are in ~/mongodb/app.env, readable only by you:

MONGODB_URI=mongodb://app:<password>@127.0.0.1:27017/app?directConnection=true
DATABASE_URL=mongodb://app:<password>@127.0.0.1:27017/app?directConnection=true
MONGODB_DB=app

The official drivers, Mongoose, and most frameworks take the URI as is; Prisma reads DATABASE_URL. The database name in the path is also where the user signs in, so there's no authSource to add. MongoDB creates the database itself on the first write, so it won't appear in lists until your app has saved something.

Want one database per app? On the VM:

~/mongodb/create-db.sh blog
cat ~/mongodb/blog.env

Each database gets its own user and password, and the nightly backup picks it up automatically.

For admin work on the VM, run ~/mongodb/mongosh.sh: it's mongosh signed in as admin, MongoDB's superuser, whose password is in ~/mongodb/admin.env. The backups use it too. Apps should use their own, less powerful user.

Run it next to your app#

Already have an app on a VM, say from a framework page? Run Install by hand on that VM, skipping only "Create a VM". Then create the app's database and add its settings to the app's environment file (remove any older MONGODB_URI or DATABASE_URL lines from it first):

~/mongodb/create-db.sh myapp
cat ~/mongodb/myapp.env >> ~/app.env

If the app runs under systemd, add After=mongod.service to its [Unit] section, so that it starts after the database at boot. MongoDB's cache takes about half the VM's memory; to leave the app more, see Size it for the VM.

From a Docker container#

Inside a container, 127.0.0.1 is the container itself. Let MongoDB listen beyond localhost, on the VM:

sudo sed -i 's/^  bindIp: 127\.0\.0\.1$/  bindIpAll: true/' /etc/mongod.conf
sudo systemctl restart mongod
timeout 60 sh -c 'until ~/mongodb/mongosh.sh --quiet --eval 1 >/dev/null 2>&1; do sleep 1; done'
ss -ltn | grep 27017

ss should show 0.0.0.0:27017 and [::]:27017. Then start containers with --add-host=host.docker.internal:host-gateway (in Compose: extra_hosts: ["host.docker.internal:host-gateway"]) and use host.docker.internal in place of 127.0.0.1 in the connection string. Every connection still needs a user and password, and nothing outside the VM can reach port 27017 anyway.

Connect from another VM#

exe.dev VMs don't share a private network, and exe.dev's VM-to-VM integration carries HTTPS only. The dependable way to reach MongoDB from another VM is to put both VMs on your Tailscale network.

On both VMs, start Tailscale (it's preinstalled) and open the login URL it prints. To join without a login link, use exe.dev's Tailscale integration instead.

sudo systemctl enable --now tailscaled
sudo tailscale up --hostname=$(hostname)

Then, on the database VM, let MongoDB listen beyond localhost (skip this if you did it for Docker):

sudo sed -i 's/^  bindIp: 127\.0\.0\.1$/  bindIpAll: true/' /etc/mongod.conf
sudo systemctl restart mongod

On the other VM, swap 127.0.0.1 for the database VM's name: mongodb://app:<password>@my-mongodb:27017/app?directConnection=true. Check it with nc -zv my-mongodb 27017. Use Tailscale's access rules if not every device on your network should reach port 27017.

You can also tunnel over SSH from VM to VM, as from your computer. The other VM then needs an SSH key added to your exe.dev account (ssh exe.dev ssh-key add), and that key opens all your VMs.

What doesn't work:

  • A public my-mongodb.exe.xyz:27017. There's no raw TCP port to open.
  • exe.dev share links and the VM-to-VM proxy. They speak HTTPS, and MongoDB's protocol isn't HTTP.
  • Hosted tools that only take a host and port. Some can use an SSH tunnel or Tailscale instead; check their docs.

Import a database#

Moving from another MongoDB server or service? Dump the old database with mongodump and load it into a fresh database here. Run the dump on the VM, so the data goes straight from server to server. You need the old database's connection string (mongodb:// or mongodb+srv://) for a user that can read it, and its name:

mongodump --uri="mongodb+srv://olduser@old.example.com/" --db=olddb \
  --gzip --archive=$HOME/import.archive.gz

It asks for the password, then lists each collection as it's dumped, ending with done dumping olddb.<collection>. Now create a database for it and load the dump there, renaming olddb on the way:

db=myapp
~/mongodb/create-db.sh $db
. ~/mongodb/admin.env
mongorestore --uri="$MONGODB_URI" --gzip --archive=$HOME/import.archive.gz \
  --nsFrom='olddb.*' --nsTo="$db.*"
~/mongodb/mongosh.sh --quiet --eval "db.getSiblingDB('$db').getCollectionNames().forEach(c => print(c, db.getSiblingDB('$db')[c].countDocuments()))"

mongorestore ends with how many documents it restored and how many failed (which should be 0). The last command lists each collection with its document count; compare a few with the old database. To try again, empty the database first (~/mongodb/mongosh.sh --eval "db.getSiblingDB('myapp').dropDatabase()"). Finally, point your app at ~/mongodb/myapp.env and delete ~/import.archive.gz.

Switching over for good? Stop writes on the old database first, take a final dump, load it, then move the app.

Keep your data#

Every night, a dump of every database and user, plus the connection files, lands in ~/mongodb/backups; the newest 14 stay. A backup on the same disk won't survive losing the VM, so keep a copy off the VM. On your computer:

scp -r my-mongodb.exe.xyz:mongodb/backups mongodb-backups

These files hold all your data and the passwords; keep them private. To undo a mistake or move to a new VM, see Back up and restore. Before any upgrade, run ~/mongodb/backup.sh on the VM.

Something odd? On the VM, sudo tail -n 20 /var/log/mongodb/mongod.log usually explains it (one JSON object per line), or ask Shelley to look.

The nuts and bolts#

Happy clicking Deploy? You can stop here. These are the same setup steps Shelley follows, plus the maintenance commands for later.

Install by hand

Create a VM#

On your computer (pick another name if my-mongodb is taken):

ssh exe.dev new --name=my-mongodb
ssh my-mongodb.exe.xyz

Adding MongoDB next to an app? Skip this step and run the rest on the app's VM. All remaining commands run on the VM, as exedev.

Install MongoDB 9.0#

Ubuntu doesn't package MongoDB. MongoDB's own apt repository has Community Edition for each Ubuntu LTS release; add it with MongoDB's signing key, on the 9.0 series:

curl -fsSL https://pgp.mongodb.com/server-9.asc | sudo gpg --dearmor --yes -o /usr/share/keyrings/mongodb-server-9.gpg
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-9.gpg ] https://repo.mongodb.org/apt/ubuntu $(. /etc/os-release; echo $VERSION_CODENAME)/mongodb-org/9.0 multiverse" |
  sudo tee /etc/apt/sources.list.d/mongodb-org-9.0.list
sudo apt-get update
apt-cache policy mongodb-org | head -n 3

The candidate should be 9.0.x. Install the server, mongosh, and the database tools (mongodump, mongorestore, and friends):

sudo DEBIAN_FRONTEND=noninteractive apt-get install -y mongodb-org
mongod --version | head -n 1
mongosh --version

Expect db version v9.0.x and a 2.x version of mongosh. The package doesn't start MongoDB yet; the next step configures it first.

Turn on access control and the replica set#

This replaces /etc/mongod.conf with one that keeps MongoDB on localhost, makes it a one-member replica set called rs0, and requires a user and password for everything. A replica set with access control also needs a key file, which its members use to trust each other:

sudo test -e /etc/mongod.keyfile ||
  openssl rand -base64 756 | sudo install -o mongodb -g mongodb -m 400 /dev/stdin /etc/mongod.keyfile
sudo tee /etc/mongod.conf >/dev/null <<'EOF'
# MongoDB settings. Reference:
# https://www.mongodb.com/docs/manual/reference/configuration-options/
storage:
  dbPath: /var/lib/mongodb
  # The WiredTiger cache defaults to half of (RAM - 1 GB). To cap it,
  # say on a VM shared with an app, uncomment these lines and set a size:
  # wiredTiger:
  #   engineConfig:
  #     cacheSizeGB: 1
systemLog:
  destination: file
  logAppend: true
  logRotate: reopen
  path: /var/log/mongodb/mongod.log
net:
  port: 27017
  bindIp: 127.0.0.1
  ipv6: true
processManagement:
  timeZoneInfo: /usr/share/zoneinfo
replication:
  replSetName: rs0
security:
  authorization: enabled
  keyFile: /etc/mongod.keyfile
EOF
sudo systemctl enable mongod
sudo systemctl restart mongod
timeout 60 sh -c 'until mongosh --quiet --eval 1 >/dev/null 2>&1; do sleep 1; done'
systemctl is-active mongod

Expect active. MongoDB now starts at boot. With no users yet, MongoDB lets a connection from the VM itself start the replica set and create the first user, and nothing else (its "localhost exception"). Start the replica set, with the member's address as 127.0.0.1:

mongosh --quiet --eval 'rs.initiate({_id: "rs0", members: [{_id: 0, host: "127.0.0.1:27017"}]})'
timeout 60 sh -c 'until mongosh --quiet --eval "db.hello().isWritablePrimary" | grep -q true; do sleep 1; done'
mongosh --quiet --eval 'db.hello().setName'

Expect { ok: 1 }, then rs0. Now create the admin user with a random password, and save its connection string to ~/mongodb/admin.env. (If that file already exists, the user does too; the script leaves both alone.)

mkdir -p -m 700 ~/mongodb
if [ ! -e ~/mongodb/admin.env ]; then
  pass=$(openssl rand -hex 24)
  mongosh --quiet admin --eval "db.createUser({user: 'admin', pwd: '$pass', roles: ['root']})" >/dev/null
  (umask 077; echo "MONGODB_URI='mongodb://admin:$pass@127.0.0.1:27017/?authSource=admin&directConnection=true'" > ~/mongodb/admin.env)
fi
cat > ~/mongodb/mongosh.sh <<'EOF'
#!/usr/bin/env bash
# mongosh, signed in as MongoDB's admin user. Arguments go to mongosh.
set -euo pipefail
. "$HOME/mongodb/admin.env"
exec mongosh "$MONGODB_URI" "$@"
EOF
chmod +x ~/mongodb/mongosh.sh

Check that a connection without a password is turned away and the admin's works:

mongosh --quiet --eval 'db.adminCommand({listDatabases: 1})' 2>&1 | tail -n 1
~/mongodb/mongosh.sh --quiet --eval 'db.adminCommand({listDatabases: 1}).databases.map(d => d.name).join(" ")'
~/mongodb/mongosh.sh --quiet --eval 'rs.status().members[0].stateStr'
ss -ltn | grep 27017

Expect MongoServerError: Command listDatabases requires authentication, then admin config local, PRIMARY, and MongoDB on 127.0.0.1:27017 only.

Size it for the VM#

MongoDB sizes its WiredTiger cache from the VM's memory when it starts: half of (RAM − 1 GB), at least 256 MB. Resize the VM and the cache follows at the next restart. See what it chose:

~/mongodb/mongosh.sh --quiet --eval 'Math.round(db.serverStatus().wiredTiger.cache["maximum bytes configured"] / 1048576) + " MB"'

On an 8 GB VM, expect about 3450 MB. That suits a VM of its own. On a VM shared with an app, cap it: uncomment the wiredTiger lines in /etc/mongod.conf, set cacheSizeGB (it takes fractions, such as 0.5), and sudo systemctl restart mongod.

MongoDB's packages don't rotate its log, so add that too. logRotate: reopen in the configuration makes MongoDB open a fresh file when logrotate signals it:

sudo tee /etc/logrotate.d/mongod >/dev/null <<'EOF'
/var/log/mongodb/mongod.log {
    weekly
    rotate 8
    compress
    delaycompress
    missingok
    notifempty
    postrotate
        systemctl kill --signal=SIGUSR1 --kill-whom=main mongod.service
    endscript
}
EOF
sudo logrotate --debug /etc/logrotate.d/mongod 2>&1 | grep '^error' || echo ok

Expect ok.

Create a database for your app#

This script creates a user with the same name as the database, gives it readWrite and dbAdmin on that database only, and writes its settings to ~/mongodb/<name>.env. It never touches an existing one:

cat > ~/mongodb/create-db.sh <<'EOF'
#!/usr/bin/env bash
# Usage: create-db.sh NAME
# Create a MongoDB user called NAME that owns the database NAME, and
# write its settings to ~/mongodb/NAME.env (mode 600).
set -euo pipefail
db=${1:?usage: create-db.sh NAME}
[[ $db =~ ^[a-z_][a-z0-9_]{0,31}$ ]] || { echo "Use up to 32 lowercase letters, digits, and _." >&2; exit 1; }
case $db in admin|config|local) echo "$db is MongoDB's own database." >&2; exit 1;; esac
env="$HOME/mongodb/$db.env"
if [ -e "$env" ]; then echo "$env already exists; leaving $db alone."; exit 0; fi
pass=$(openssl rand -hex 24)
"$HOME/mongodb/mongosh.sh" --quiet --eval "db.getSiblingDB('$db').createUser({user: '$db', pwd: '$pass',
  roles: [{role: 'readWrite', db: '$db'}, {role: 'dbAdmin', db: '$db'}]})" >/dev/null
url="mongodb://$db:$pass@127.0.0.1:27017/$db?directConnection=true"
(umask 077; printf 'MONGODB_URI=%s\nDATABASE_URL=%s\nMONGODB_DB=%s\n' "$url" "$url" "$db" > "$env")
echo "$env"
EOF
chmod +x ~/mongodb/create-db.sh
~/mongodb/create-db.sh app

Check that the app's user can sign in, write, use a transaction, and watch a change stream, and can't see other databases:

. ~/mongodb/app.env
mongosh --quiet "$MONGODB_URI" --eval '
  const cs = db.check.watch();
  cs.disableBlockWarnings();
  const s = db.getMongo().startSession();
  s.startTransaction();
  s.getDatabase("app").check.insertOne({note: "Grüße ✓"});
  s.commitTransaction();
  print(cs.next().operationType, db.check.findOne().note);
  db.check.drop();
  try { db.getSiblingDB("admin").system.users.findOne() } catch (e) { print(e.codeName) }'
stat -c '%a %n' ~/mongodb/app.env

Expect insert Grüße ✓, then Unauthorized, then 600.

Set up nightly backups#

This script dumps every database and every user with mongodump, into one compressed archive. --oplog also records the writes that happen while it runs, so a restore lands on one moment in time. Beside the archive it saves the .env files, and it keeps the 14 newest backups:

cat > ~/mongodb/backup.sh <<'EOF'
#!/usr/bin/env bash
# Back up every database and user, and ~/mongodb/*.env, into
# ~/mongodb/backups/<UTC time>/. Keeps the 14 newest backups.
set -euo pipefail
umask 077
dest="$HOME/mongodb/backups"
keep=14
mkdir -p "$dest"
out="$dest/$(date -u +%Y%m%d-%H%M%S)"
while [ -e "$out" ] || [ -e "$out.part" ]; do sleep 1; out="$dest/$(date -u +%Y%m%d-%H%M%S)"; done
trap 'rm -rf "$out.part"' EXIT
mkdir "$out.part"

admin=$(. "$HOME/mongodb/admin.env"; echo "$MONGODB_URI")
mongodump --uri="$admin" --oplog --gzip --archive="$out.part/dump.archive.gz" 2> "$out.part/mongodump.log" ||
  { cat "$out.part/mongodump.log" >&2; exit 1; }
cp -p "$HOME"/mongodb/*.env "$out.part/"

mv "$out.part" "$out"
ls -1d "$dest"/2*/ | head -n -"$keep" | xargs -r rm -rf --
echo "$out"
EOF
chmod +x ~/mongodb/backup.sh

Schedule it, then take a first backup:

sudo tee /etc/systemd/system/mongodb-backup.service >/dev/null <<EOF
[Unit]
Description=Back up MongoDB to ~/mongodb/backups
After=mongod.service

[Service]
Type=oneshot
User=$USER
ExecStart=$HOME/mongodb/backup.sh
EOF
sudo tee /etc/systemd/system/mongodb-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up MongoDB every night

[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now mongodb-backup.timer
sudo systemctl start mongodb-backup.service
systemctl list-timers mongodb-backup.timer
ls -l ~/mongodb/backups/*/

Expect a next run time, and admin.env, app.env, dump.archive.gz, and mongodump.log. For failures: journalctl -u mongodb-backup.service.

Test a backup#

Put a document with some non-ASCII text in a scratch database, take a backup, and restore it into a scratch MongoDB server on port 27019, exactly as you would on a new VM:

~/mongodb/mongosh.sh --quiet --eval 'db.getSiblingDB("restore_check").t.insertOne({_id: 1, note: "Grüße ✓"})'
latest=$(~/mongodb/backup.sh)
s=/tmp/mongodb-scratch
mkdir -m 700 $s
mongod --dbpath $s --port 27019 --bind_ip 127.0.0.1 --fork --logpath $s/mongod.log >/dev/null
mongorestore --uri=mongodb://127.0.0.1:27019/ --gzip --archive="$latest/dump.archive.gz" --oplogReplay
mongosh --quiet mongodb://127.0.0.1:27019/restore_check --eval 'db.t.findOne().note'
. "$latest/app.env"
mongosh --quiet "${MONGODB_URI/27017/27019}" --eval 'db.runCommand({connectionStatus: 1}).authInfo.authenticatedUsers[0].user'

mongorestore should end with 0 document(s) failed to restore (lines saying it don't know what to do with subdirectory are normal for an archive), then expect Grüße ✓ and app: the restored user still signs in with the password from the backup. Stop and remove the scratch server, and drop the scratch database:

mongod --dbpath $s --shutdown >/dev/null
rm -rf $s
~/mongodb/mongosh.sh --quiet --eval 'db.getSiblingDB("restore_check").dropDatabase(); db.adminCommand({listDatabases: 1}).databases.map(d => d.name).join(" ")'

The list should show only MongoDB's own admin config local (app appears once your app writes to it). Installation done; continue with Connect from your computer.

Back up and restore

On the VM, ~/mongodb/backup.sh takes an extra backup and prints its folder. Each folder has dump.archive.gz (every database, user, and role), the .env files, and mongodump.log. mongorestore reads the archive; its lines saying it don't know what to do with subdirectory are normal.

Undo a mistake#

Dropped the wrong collection? Restore a backup next to the live database. On the VM, pick a backup folder from the list:

ls ~/mongodb/backups/
when=20261031-033012
db=app
. ~/mongodb/admin.env
mongorestore --uri="$MONGODB_URI" --gzip --archive=$HOME/mongodb/backups/$when/dump.archive.gz \
  --nsInclude="$db.*" --nsFrom="$db.*" --nsTo="${db}_restored.*"
~/mongodb/mongosh.sh --quiet --eval "db.getSiblingDB('${db}_restored').getCollectionNames()"

Say the lost collection was orders. Copy it back with $out, which replaces any collection of that name in app, then drop the copy:

~/mongodb/mongosh.sh --quiet --eval '
  db.getSiblingDB("app_restored").orders.aggregate([{$out: {db: "app", coll: "orders"}}]);
  db.getSiblingDB("app_restored").dropDatabase()'

($out copies documents but not indexes; recreate those, or let your app do it on start.) To roll the whole database back instead, stop your app, keep a copy of its current state, and restore the backup over it. --drop replaces each collection that's in the backup; users stay as they are:

when=20261031-033012
db=app
. ~/mongodb/admin.env
mongodump --uri="$MONGODB_URI" --db=$db --gzip --archive=$HOME/$db-before-rollback.archive.gz
mongorestore --uri="$MONGODB_URI" --gzip --archive=$HOME/mongodb/backups/$when/dump.archive.gz \
  --nsInclude="$db.*" --drop

Start the app again, and delete ~/app-before-rollback.archive.gz once you're happy.

Restore everything on a new VM#

On a new VM, follow Install by hand up to and including "Size it for the VM", but don't create the app database. Then copy a backup folder over. On your computer:

scp -r mongodb-backups/20261031-033012 my-mongodb.exe.xyz:restore

On the VM, restore everything, users included. First give the new admin user the old password, so that it's the same before and after the restore, and let the old .env files take over:

oldpass=$(sed -nE 's|.*//admin:([^@]*)@.*|\1|p' ~/restore/admin.env)
~/mongodb/mongosh.sh --quiet --eval "db.getSiblingDB('admin').changeUserPassword('admin', '$oldpass')" >/dev/null
cp ~/restore/*.env ~/mongodb/
chmod 600 ~/mongodb/*.env
. ~/mongodb/admin.env
mongorestore --uri="$MONGODB_URI" --gzip --archive=$HOME/restore/dump.archive.gz --oplogReplay
~/mongodb/mongosh.sh --quiet --eval 'db.adminCommand({listDatabases: 1}).databases.map(d => d.name).join(" ")'

mongorestore should end with 0 document(s) failed to restore, and every database should be listed. Continue Install by hand from "Create a database for your app" (it leaves the existing app alone), and rm -r ~/restore when you're done. If the old VM had Docker or Tailscale access, redo its bindIpAll change too.

More often#

Nightly dumps can lose up to a day of changes. To back up hourly and keep two days, on the VM:

sudo sed -i 's/^OnCalendar=.*/OnCalendar=hourly/; s/^RandomizedDelaySec=.*/RandomizedDelaySec=5m/' /etc/systemd/system/mongodb-backup.timer
sed -i 's/^keep=.*/keep=48/' ~/mongodb/backup.sh
sudo systemctl daemon-reload && sudo systemctl restart mongodb-backup.timer
systemctl list-timers mongodb-backup.timer

Each backup is a full dump, so this suits databases up to a few GB. exe.dev already replicates the VM's disk to other machines, which guards against hardware failure, not against a bad deleteMany.

Update MongoDB

Updates don't install themselves on this VM, so check for them now and then. MongoDB lists each patch release, and the security fixes in it, in the 9.0 release notes.

Minor updates#

On the VM, back up, upgrade just the MongoDB packages, then restart MongoDB onto the new version. Apps see a few seconds of errors:

~/mongodb/backup.sh
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y -o Dpkg::Options::=--force-confold \
  $(dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' 'mongodb-*' | awk '$1 == "ii" {print $2}')
sudo systemctl restart mongod
timeout 60 sh -c 'until ~/mongodb/mongosh.sh --quiet --eval 1 >/dev/null 2>&1; do sleep 1; done'
~/mongodb/mongosh.sh --quiet --eval 'db.version()'

--force-confold keeps your /etc/mongod.conf.

Major upgrades#

9.0 is supported until October 31, 2031, so there's no rush. MongoDB upgrades one major release at a time, and each one changes the data files once you raise the "feature compatibility version" (FCV) at the end; until then you can still go back. Read the new release's upgrade notes and check that your drivers support it, then take its repository line and key from MongoDB's install guide. Stop your apps, and on the VM:

~/mongodb/backup.sh
~/mongodb/mongosh.sh --quiet --eval 'db.adminCommand({getParameter: 1, featureCompatibilityVersion: 1}).featureCompatibilityVersion.version'

The FCV must be 9.0 before you start. Replace /etc/apt/sources.list.d/mongodb-org-9.0.list and the key with the new ones, run sudo apt-get update, the same apt-get install line as in Install by hand, and sudo systemctl restart mongod. Check db.version() and your app. When you're sure you'll stay, raise the FCV in ~/mongodb/mongosh.sh, with the new version number:

db.adminCommand({setFeatureCompatibilityVersion: "10.0", confirm: true})
Something not working?

On the VM, MongoDB's status and log usually have the answer. Each log line is a JSON object; "s":"E" marks errors:

systemctl status mongod
sudo tail -n 30 /var/log/mongodb/mongod.log
sudo journalctl -u mongod -n 20
  • It won't start, and the journal says BadValue: a setting in /etc/mongod.conf is wrong. YAML wants spaces, not tabs, and security.keyFile is required alongside authorization and replSetName. The journal names the bad setting.

  • ECONNREFUSED 127.0.0.1:27017: MongoDB isn't running. Start it with sudo systemctl start mongod and read the log if it stops.

  • Authentication failed: the password doesn't match the .env file, or the connection string is missing the database name (the user signs in to its own database, /app). To set a new password in both places, then restart the app:

    pass=$(openssl rand -hex 24)
    ~/mongodb/mongosh.sh --quiet --eval "db.getSiblingDB('app').changeUserPassword('app', '$pass')" >/dev/null
    sed -i "s|//app:[^@]*@|//app:$pass@|" ~/mongodb/app.env
    
  • Connections time out with Server selection timed out, often mentioning 127.0.0.1:27017 from another machine or a container: the connection string lacks directConnection=true, so the driver went looking for the replica set's member address. Add it.

  • Transaction numbers are only allowed on a replica set member or mongos or The $changeStream stage is only supported on replica sets: this MongoDB wasn't set up as a replica set. Run "Turn on access control and the replica set" from Install by hand.

  • not authorized on admin to execute command: the app's user only has rights in its own database. Do admin work with ~/mongodb/mongosh.sh.

  • Startup warnings: Using the XFS filesystem is strongly recommended and Failed to read file (for /sys/kernel/mm/transparent_hugepage) are expected. The VM's disk is ext4, which MongoDB supports, and its kernel has no transparent huge pages for MongoDB to tune. Nothing to do.

  • It won't start after making the VM smaller with a cacheSizeGB set: lower it in /etc/mongod.conf, or comment it out to go back to the automatic size.

  • The tunnel says Address already in use: something on your computer has port 27018. Pick another, such as -L 27020:localhost:27017.

  • SSH refuses the tunnel: try the form that always works, ssh -N -L 27018:localhost:27017 vm+my-mongodb@vm.exe.xyz.

  • Disk full: MongoDB stops. Check df -h / and sudo du -sh ~/mongodb/backups /var/lib/mongodb. The replica set's operation log takes about 1 GB of that, and stays that size. Lower keep in ~/mongodb/backup.sh after copying old backups off the VM, or grow the disk: on your computer, ssh exe.dev resize my-mongodb --disk=50GB.

  • Slow queries: MongoDB logs every operation slower than 100 ms. Find them with sudo grep '"Slow query"' /var/log/mongodb/mongod.log | tail -n 5, and ask MongoDB how it runs one with .explain("executionStats") in mongosh. A COLLSCAN on a big collection usually wants an index.

Frequently asked questions#

Is this a managed database?

No, it's yours. You get root on the VM, the real /etc/mongod.conf, and every feature of MongoDB Community Edition. Updates are yours to apply, too; Update MongoDB shows how, and Shelley on the VM can do them for you.

Can I connect to port 27017 from the internet?

Not directly. exe.dev only lets HTTPS and SSH in, so you connect through an SSH tunnel, or from other machines on your Tailscale network. The database never faces the open internet, and it asks for a password anyway.

Why a replica set with only one member?

Multi-document transactions and change streams only work on a replica set, and some libraries, such as Prisma's MongoDB connector, refuse to write without one. A one-member set costs about a gigabyte of disk for its operation log, and makes the nightly backup a consistent snapshot. Connection strings need directConnection=true; the ones this page writes have it.

Is there a web admin?

Not on this page. MongoDB Compass, MongoDB's free desktop app, does far more than the web admins for MongoDB, and it connects through an SSH tunnel with nothing extra to run or secure on the VM. On the VM, mongosh does the rest.

Why MongoDB 9.0?

It's MongoDB's newest major release, out since September 2026 and supported until October 31, 2031. MongoDB 8.0 is supported until October 31, 2029; if an app or driver isn't ready for 9.0, install 8.0 with the repository line and key from MongoDB's 8.0 install guide.

Do I get point-in-time recovery?

No. You get nightly dumps, so you could lose up to a day of changes; you can back up hourly instead.

Is MongoDB free and open source?

MongoDB Community Edition is free to use, and its source is public under the Server Side Public License (SSPL) v1. The SSPL isn't an OSI-approved open source license: if you offer MongoDB itself to others as a service, it requires you to publish the source of your whole service. Running it as your own apps' database, as here, triggers none of that. The drivers, mongosh, and the database tools are Apache 2.0. You pay for the VM; see exe.dev plans.

Updated Oct 31, 2026 · tested end to end Oct 31, 2026 · Markdown · manifest.json · Something wrong? Tell us