---
title: "Self-host MongoDB"
description: "Run MongoDB 9.0 Community Edition on an exe.dev VM: access control on, transactions and change streams, nightly tested backups, and SSH tunnels."
url: https://exe-staging.dev/deploy/mongodb
category: "Databases"
updated: 2026-10-31
---

# Self-host MongoDB

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

- **Software:** MongoDB (https://www.mongodb.com/products/self-managed/community-edition)
- **Source:** https://github.com/mongodb/mongo
- **License:** SSPL-1.0
- **Time:** 10 minutes
- **Example VM name:** `my-mongodb` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe-staging.dev/deploy/mongodb/manifest.json

## 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.

Running on the VM:

- **MongoDB 9.0**: Community Edition from MongoDB's apt repository, a one-member replica set, port 27017, data in `/var/lib/mongodb`
- **Nightly backups**: mongodump of every database and user, 14 nights kept, data in `~/mongodb/backups`

## Deploy with Shelley

Open https://exe-staging.dev/new?prompt=Set%20up%20MongoDB%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe-staging.dev%2Fdeploy%2Fmongodb.md%20from%20top%20to%20bottom.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-mongodb.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-mongodb.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20MongoDB%209.0%20Community%20Edition%2C%20started%20at%20boot%2C%20listening%20on%20localhost%20only%2C%20with%20access%20control%20on.%0A-%20A%20one-member%20replica%20set%2C%20so%20transactions%20and%20change%20streams%20work.%0A-%20An%20%60admin%60%20user%2C%20and%20an%20%60app%60%20database%20with%20its%20own%20user%20and%20connection%20string%20in%20%60~%2Fmongodb%2Fapp.env%60.%0A-%20Nightly%20backups%20with%20%60mongodump%60%2C%20one%20restored%20with%20%60mongorestore%60%20and%20checked.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details.%20For%20initial%20setup%2C%0Afollow%20%22Install%20by%20hand%22%20from%20top%20to%20bottom%3A%20the%20MongoDB%20repository%20and%0Aserver%2C%20the%20replica%20set%20and%20access%20control%2C%20log%20rotation%2C%20the%20app%0Adatabase%2C%20the%20backup%20timer%2C%20and%20the%20scratch-server%20restore%20test.%20The%0Awalkthrough%20sections%20are%20for%20the%20owner%20afterwards%3A%20don%27t%20import%20data%2C%0Aset%20up%20Tailscale%20or%20Docker%20access%2C%20or%20run%20the%20Restore%20or%20Update%0Ainstructions%20during%20initial%20setup.%20Never%20overwrite%20an%20existing%0A~%2Fmongodb%2F%2A.env%20file%20or%20%2Fetc%2Fmongod.keyfile.%20Leave%20the%20VM%20private%20and%0Ado%20not%20reboot.%20Don%27t%20paste%20passwords%20into%20the%20chat%3B%20say%20they%20are%20in%0A~%2Fmongodb%2Fapp.env%20and%20~%2Fmongodb%2Fadmin.env.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open%20and%20these%20commands%2C%20which%20only%20I%20can%20run%2C%20from%20my%20own%20computer%20%28with%20this%20VM%27s%20name%20in%20them%29%3A%0A%20%20ssh%20my-mongodb.exe.xyz%20cat%20mongodb%2Fapp.env%0A%20%20ssh%20-N%20-L%2027018%3Alocalhost%3A27017%20my-mongodb.exe.xyz%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe-staging.dev/deploy/mongodb/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Then, on your own computer:

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

Or follow the steps below by hand.

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.

## 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:

```bash
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](https://www.mongodb.com/try/download/shell), using the
`MONGODB_URI` from `app.env` with the port changed to 27018:

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

**Compass.** [MongoDB Compass](https://www.mongodb.com/products/tools/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:

```text
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:

```bash
~/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](/deploy)? Run
[Install by hand](#create-the-vm) 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):

```bash
~/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](#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:

```bash
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](/docs/integrations-vm-to-vm) carries HTTPS only.
The dependable way to reach MongoDB from another VM is to put both VMs
on your [Tailscale](https://tailscale.com/) 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](/docs/integrations-tailscale-wif) instead.

```bash
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):

```bash
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](https://tailscale.com/kb/1018/acls) 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:

```bash
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:

```bash
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:

```bash
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](#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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Install by hand</summary>

### Create a VM

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

```bash
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:

```bash
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):

```bash
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:

```bash
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`:

```bash
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.)

```bash
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:

```bash
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:

```bash
~/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:

```bash
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:

```bash
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:

```bash
. ~/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:

```bash
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:

```bash
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:

```bash
~/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:

```bash
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](#connect-from-your-computer).

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

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:

```bash
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:

```bash
~/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:

```bash
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](#create-the-vm) 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:

```bash
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:

```bash
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:

```bash
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](/docs/what-is-exe) to other
machines, which guards against hardware failure, not against a bad
`deleteMany`.

</details>

<details id="update" class="dp-details">
<summary>Update MongoDB</summary>

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](https://www.mongodb.com/docs/manual/release-notes/9.0/).

### 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:

```bash
~/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](https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-ubuntu/).
Stop your apps, and on the VM:

```bash
~/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:

```js
db.adminCommand({setFeatureCompatibilityVersion: "10.0", confirm: true})
```

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

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

```bash
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:

  ```bash
  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.

</details>

## 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](#update) 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](#connect-from-your-computer), or from other machines on your [Tailscale network](#connect-from-another-vm). 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](#connect-from-your-computer) 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](https://www.mongodb.com/docs/v8.0/tutorial/install-mongodb-on-ubuntu/).

### 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](#more-often) 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](https://exe.dev/pricing).


## Related

- [Self-host PostgreSQL](https://exe-staging.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Self-host MySQL](https://exe-staging.dev/deploy/mysql.md): Your own MySQL on a real disk, private by default, with backups you've restored.
- [Deploy Next.js on your own server](https://exe-staging.dev/deploy/nextjs.md): Your Next.js app on a server of its own. git push to deploy.
- [Deploy a FastAPI app](https://exe-staging.dev/deploy/fastapi.md): Your API on a VM of its own. Push to deploy; the data stays put.
- [Self-host Redis](https://exe-staging.dev/deploy/redis.md): Your own Redis on a real disk, private by default, with backups you've restored.
- [Self-host n8n](https://exe-staging.dev/deploy/n8n-2.md): Connect your apps. Let the boring bits run themselves.
