GuidesDevelopers and hostingFerretDB database server

Run a FerretDB database server with rootless Podman

FerretDB 2.7 on Debian 13, the open-source database that speaks MongoDB's protocol and stores its data in PostgreSQL, in rootless Podman under a user of its own, with logins, TLS, access only over WireGuard and nightly backups you have restored.

Tested on FerretDB 2.7.0 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

FerretDB is a database that speaks MongoDB's protocol, so apps, drivers and tools made for MongoDB, such as mongosh and mongodump, work with it. Underneath, it stores the documents in PostgreSQL, through the DocumentDB extension. This guide sets it up as your own database server: logins for you and your app, TLS for every connection, settings sized for the server, access only from this server and over WireGuard, and nightly backups.

FerretDB is developed by FerretDB Inc. and is open source under the Apache 2.0 licence. The DocumentDB extension, started by Microsoft, is open source under the MIT licence. MongoDB is a trademark of MongoDB, Inc.; FerretDB is not MongoDB, and does not do everything MongoDB does. Step 9 lists what we found missing. If your app needs those features, use MongoDB itself.

FerretDB's newest release is 2.7.0, from November 2025. When we checked its GitHub page on 14 October 2026, the main branch had only seen dependency updates since then, the last in February 2026, so the project looks quiet. Check the page for new releases before you build on it.

Out of the box, FerretDB sends anonymous usage statistics to beacon.ferretdb.com, starting an hour after it starts, and checks for new versions. Step 4 switches both off.

Here FerretDB runs as a pod under a user of its own called ferretdb, from the images FerretDB publishes: one with PostgreSQL 17 and DocumentDB, and one with FerretDB itself. 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:

  • FerretDB 2.7.0 started with PostgreSQL 17.6 and DocumentDB 0.107.0, 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.
  • 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 aggregations, joins, text search, geo queries and several kinds of index worked. Step 9 lists what did not.
  • A nightly backup was restored twice: the dump with mongorestore into a new database, and the PostgreSQL copy into an empty volume. Both came back complete, with their indexes.
  • An update from FerretDB 2.5.0 to 2.7.0, with the steps in step 11, went through on a separate test pod. The server's certificate was renewed, and everything came back by itself after a reboot.

After a reboot, the whole server used 570 MB of memory, and 800 MB after queries over the 100,000 documents. FerretDB itself used about 10 MB. PostgreSQL's cache takes more as it fills with your data, up to the 2 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 ferretdb
loginctl enable-linger ferretdb
machinectl shell ferretdb@

Everything up to step 6 runs as ferretdb.

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=FerretDB CA" -keyout ca.key -out ca.crt
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc -subj /CN=ferretdb -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

openssl rand -hex 24 | tr -d '\n' | podman secret create ferretdb-pg-password -
podman secret create ferretdb-tls-cert ~/tls/server.crt
podman secret create ferretdb-tls-key ~/tls/server.key

The first is the password of postgres, PostgreSQL's superuser. You will not need to type it. The other two give FerretDB its certificate and key. Podman stores them, not the files below.

4. Define the pod

mkdir -p ~/.config/containers/systemd ~/initdb

When the database is created, PostgreSQL makes a role for FerretDB to work as. Create ~/initdb/30-ferretdb-role.sh:

# Runs once, when the database is created: the PostgreSQL role FerretDB works as
psql -v ON_ERROR_STOP=1 -U postgres -d postgres -c "CREATE ROLE ferretdb LOGIN IN ROLE documentdb_admin_role"
chmod 644 ~/initdb/30-ferretdb-role.sh
cd ~/.config/containers/systemd

Create ferretdb.pod:

[Pod]
PodName=ferretdb
# Room for PostgreSQL's parallel queries (the default is 64 MB)
ShmSize=256m
# 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

[Install]
WantedBy=default.target

Create ferretdb-pg.container:

[Container]
ContainerName=ferretdb-pg
Image=ghcr.io/ferretdb/postgres-documentdb:17-0.107.0-ferretdb-2.7.0
Pod=ferretdb.pod
Volume=ferretdb-pg:/var/lib/postgresql/data
Volume=%h/initdb/30-ferretdb-role.sh:/docker-entrypoint-initdb.d/30-ferretdb-role.sh:ro
Environment=POSTGRES_USER=postgres POSTGRES_DB=postgres POSTGRES_PASSWORD_FILE=/run/secrets/ferretdb-pg-password
Secret=ferretdb-pg-password
# Sized for 2 vCPU and 8 GB of memory
Exec=postgres -c shared_buffers=2GB -c effective_cache_size=6GB -c work_mem=16MB -c maintenance_work_mem=512MB -c random_page_cost=1.1
HealthCmd=pg_isready -h 127.0.0.1 -U postgres
HealthInterval=10s
Notify=healthy

[Service]
Restart=always
TimeoutStartSec=300

And ferretdb.container:

[Unit]
Requires=ferretdb-pg.service
After=ferretdb-pg.service

[Container]
ContainerName=ferretdb
Image=ghcr.io/ferretdb/ferretdb:2.7.0
Pod=ferretdb.pod
Environment=FERRETDB_POSTGRESQL_URL=postgres://ferretdb@127.0.0.1:5432/postgres
# TLS only: no listener without encryption
Environment=FERRETDB_LISTEN_ADDR=- FERRETDB_LISTEN_TLS=:27017
Environment=FERRETDB_LISTEN_TLS_CERT_FILE=/run/secrets/ferretdb-tls-cert FERRETDB_LISTEN_TLS_KEY_FILE=/run/secrets/ferretdb-tls-key
Environment=FERRETDB_TELEMETRY=disable
Secret=ferretdb-tls-cert,uid=1000
Secret=ferretdb-tls-key,uid=1000

[Service]
Restart=always

What the settings do:

  • Both containers share the pod's network, so FerretDB reaches PostgreSQL on 127.0.0.1:5432 inside the pod. PostgreSQL's port is not published, so nothing outside the pod can reach it.
  • Inside the pod, PostgreSQL lets local connections in without a password, as the image sets it up. DocumentDB needs that: it opens connections to itself for its background jobs and for some queries. When we required passwords there, text search, regular expressions and several aggregations failed with fe_sendauth: no password supplied.
  • FerretDB works in PostgreSQL as the role ferretdb, which can read and write documents but is not a superuser. The logins in step 5 only decide who may connect; what is done after that is done as ferretdb.
  • FERRETDB_LISTEN_ADDR=- switches off the listener without encryption, and FERRETDB_LISTEN_TLS puts the TLS listener on port 27017, the port MongoDB clients expect.
  • FERRETDB_TELEMETRY=disable stops the usage statistics and the version check.
  • uid=1000 lets FerretDB, which runs as user 1000 in its container, read its certificate and key.
  • The image tags are pinned, because an update needs a step by hand (step 11).

The Exec line sizes PostgreSQL for 8 GB of memory, as in the PostgreSQL guide:

  • shared_buffers is PostgreSQL's own cache. A quarter of the memory is the usual starting point, because the rest is used by the system's file cache, which PostgreSQL reads through as well.
  • effective_cache_size reserves nothing. It tells the planner how much of the data is likely in memory, counting the file cache.
  • work_mem is what one sort or join may use before it spills to disk. A complex query can use it several times over, so keep it modest.
  • maintenance_work_mem speeds up VACUUM and index builds.
  • random_page_cost = 1.1 tells the planner that random reads are cheap on SSD storage.
  • ShmSize in the pod gives PostgreSQL 256 MB of shared memory for parallel queries, instead of Podman's 64 MB.

Start it:

systemctl --user daemon-reload
systemctl --user start ferretdb-pod

The first start downloads about 1.8 GB of images, and PostgreSQL takes a little while to create its database. It is ready when this prints active:

systemctl --user is-active ferretdb

5. Create the logins

FerretDB keeps no users of its own: a login is a PostgreSQL role with a password. Make one for yourself, admin, and one for the app, with their passwords as secrets:

for u in admin appuser; do
  openssl rand -hex 24 | tr -d '\n' | podman secret create ferretdb-$u-password -
  printf "CREATE ROLE %s LOGIN PASSWORD '%s';\n" $u "$(podman secret inspect --showsecret --format '{{.SecretData}}' ferretdb-$u-password)" | podman exec -i ferretdb-pg psql -U postgres
done

Each prints CREATE ROLE. The passwords go to psql on its input, so they do not show in the list of processes. To see one, for example to give it to the app:

podman secret inspect --showsecret --format '{{.SecretData}}' ferretdb-appuser-password

What a login may do. FerretDB 2.7 has no roles or permissions: once logged in, every login can read and write every database. We checked: appuser could read and change another app's database, and the roles you give createUser, such as read, were ignored. So the two logins differ only in their passwords: the app's can be changed or removed without locking you out. If two apps must not see each other's data, give each its own FerretDB, with its own user, pod and port.

Logins are created with psql, as above. createUser in mongosh fails with permission denied to create role, because FerretDB works as ferretdb, which may not create roles. That is on purpose: when FerretDB worked as the superuser postgres, as in FerretDB's own examples, every user made with createUser became a PostgreSQL superuser.

Use mongosh. The mongo image has mongosh, mongodump and mongorestore. mongosh sends usage statistics to MongoDB by default; a small settings file switches that off. Create it, and an alias that runs mongosh in the pod:

printf 'mongosh:\n  forceDisableTelemetry: true\n' > ~/mongosh.conf
chmod 644 ~/mongosh.conf
echo "alias mongosh='podman run --rm -it --pod ferretdb -v ~/tls/ca.crt:/ca.crt:ro -v ~/mongosh.conf:/etc/mongosh.conf:ro docker.io/library/mongo:9.0 mongosh'" >> ~/.bashrc
source ~/.bashrc
mongosh "mongodb://admin@localhost:27017/?tls=true&tlsCAFile=/ca.crt"

The first run downloads the mongo image, about 1.1 GB. mongosh asks for the password, and warns that This server or service appears to be an emulation of MongoDB, which is true. exit leaves it.

6. Let your app servers in

Go back to root with exit. The pod listens on the tunnel address 10.8.0.1, which exists only once WireGuard is up. If the pod 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=1170,fd=7))
LISTEN 0      128        127.0.0.1:27017      0.0.0.0:*    users:(("pasta.avx2",pid=1170,fd=6))

Only the tunnel address and 127.0.0.1: nothing listens on the public address. We stopped WireGuard and restarted the pod: without the setting it failed, with it the pod started, and took connections as soon as WireGuard came back.

7. 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 FerretDB 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: unable to get local issuer certificate
MongoServerError: Authentication failed.

From the app server, PyMongo without TLS got connection closed in the same way.

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

appdb at the end is the database the app uses by default. FerretDB creates it when the app first writes to it. The passwords from step 5 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' python3 /opt/notes/notes.py
Hello from the app
1 notes in total

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

9. What FerretDB does not do

We ran the same tests against FerretDB 2.7.0 and MongoDB 9.0. These gave the same results on both: inserts, updates, findOneAndUpdate, bulkWrite, creating unique, compound, partial, wildcard, text, TTL and 2dsphere indexes, $text search, regular expressions, and the aggregation stages $group, $lookup (also with a pipeline), $unwind, $facet, $bucket, $graphLookup, $setWindowFields, $unionWith, $sample, $densify, $out and $merge.

These did not work on FerretDB:

  • Transactions were accepted but did nothing: a document written in a transaction was visible to others before the commit, and was still there after abortTransaction.
  • Change streams (watch()) failed with Stage $changeStream is not supported yet.
  • Roles and permissions: every login can do everything (step 5).
  • Views were created, but returned no documents.
  • Collation: a case-insensitive search with collation({locale: "sv", strength: 2}) found nothing.
  • Schema validation: a $jsonSchema validator was accepted, but documents that broke it were stored anyway.
  • Capped collections kept growing past their max, and a time series collection became an ordinary collection.
  • Hidden indexes, collMod, the profiler and mapReduce failed. $where failed too, as it does on MongoDB 9.0 by default.
  • Moving from MongoDB: mongorestore of a dump from MongoDB restored the documents but not the indexes (see Troubleshooting).

Many apps use none of these. Before you move an app, run its own tests against FerretDB.

10. Back up

Back up two ways: mongodump writes every database in MongoDB's format, which you can restore to FerretDB or to MongoDB, and pg_basebackup copies PostgreSQL's data, logins included. As ferretdb, create ~/bin/ferretdb-backup:

mkdir -p ~/bin ~/.config/systemd/user
#!/bin/sh
# Dump every database with mongodump, copy PostgreSQL's data, and keep 14 days
set -e
umask 077
dir=$HOME/backup
day=$(date +%F)
mkdir -p "$dir"
podman run --rm --pod ferretdb --secret ferretdb-admin-password \
  -v "$HOME/tls/ca.crt:/ca.crt:ro" -v "$dir:/backup" \
  --entrypoint sh docker.io/library/mongo:9.0 -c '
    umask 077
    printf "password: %s\n" "$(cat /run/secrets/ferretdb-admin-password)" > /tmp/auth.yaml
    mongodump --quiet --config=/tmp/auth.yaml --gzip --archive=/backup/mongodump-'"$day"'.gz \
      --uri="mongodb://admin@localhost:27017/?tls=true&tlsCAFile=/ca.crt"'
podman exec ferretdb-pg pg_basebackup -U postgres -D - -Ft -X fetch --checkpoint=fast | gzip > "$dir/postgres-$day.tar.gz"
find "$dir" -type f -mtime +13 -delete

The password reaches mongodump in a file inside the throwaway container, not on its command line. The container does not inherit the script's umask 077, so it sets its own: without it, the dump was readable by every user on the server. Run it every night with a service and a timer. Create ~/.config/systemd/user/ferretdb-backup.service:

[Unit]
Description=Back up FerretDB

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

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

[Unit]
Description=Back up FerretDB every night

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

[Install]
WantedBy=timers.target

Switch it on, and run it once now:

chmod 755 ~/bin/ferretdb-backup
systemctl --user daemon-reload
systemctl --user enable --now ferretdb-backup.timer
systemctl --user start ferretdb-backup.service
ls -l ~/backup
-rw------- 1 ferretdb ferretdb  2613984 Oct 14 02:01 mongodump-2026-10-14.gz
-rw------- 1 ferretdb ferretdb 11035086 Oct 14 02:01 postgres-2026-10-14.tar.gz

For our 100,000 test documents it took 8 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 of the dump. Restore one database under a new name, next to the real one:

podman run --rm --pod ferretdb -v ~/tls/ca.crt:/ca.crt:ro -v ~/backup:/backup \
  --entrypoint mongorestore docker.io/library/mongo:9.0 \
  --uri="mongodb://admin:ADMIN_PASSWORD@localhost:27017/?tls=true&tlsCAFile=/ca.crt" \
  --gzip --archive=/backup/mongodump-2026-10-14.gz \
  --nsInclude='appdb.*' --nsFrom='appdb.*' --nsTo='appdb_restore.*'
125014 document(s) restored successfully. 0 document(s) failed to restore.

Our appdb_restore had the same 100,000 orders and 5,000 customers 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 PostgreSQL's copy. This puts back the whole database server as it was, logins included, for example after a broken update. Into an empty volume:

systemctl --user stop ferretdb-pod
podman volume rm ferretdb-pg
podman volume create ferretdb-pg
gunzip -c ~/backup/postgres-2026-10-14.tar.gz | podman volume import ferretdb-pg -
systemctl --user start ferretdb-pod

PostgreSQL replays the little it wrote during the copy, and starts. Ours had all 100,000 documents, and text search worked. The copy only works with the same PostgreSQL major version, 17 here; the dump works with any.

11. Update

FerretDB and DocumentDB are released together, and each FerretDB release names the PostgreSQL image that goes with it. 2.7.0 was the newest when we tested. We ran the same steps from 2.5.0 to 2.7.0 on a separate test pod. As ferretdb, take a backup first:

systemctl --user start ferretdb-backup.service

Change the tags in both files, ferretdb-pg.container and ferretdb.container, to the new release, and restart:

systemctl --user daemon-reload
systemctl --user restart ferretdb-pod

When systemctl --user is-active ferretdb prints active, as in step 4, update the extension in the database, and restart FerretDB:

podman exec ferretdb-pg psql -U postgres -d postgres -c 'ALTER EXTENSION documentdb UPDATE;'
systemctl --user restart ferretdb

ALTER EXTENSION prints ALTER EXTENSION. In our test, FerretDB 2.7.0 read the old data even before it. SELECT extversion FROM pg_extension WHERE extname = 'documentdb' showed 0.106-0 before and 0.107-0 after, the documents were all there, and new writes worked. Read the release notes on FerretDB's GitHub page before each update. A new PostgreSQL major version in the image would need the data moved with mongodump and mongorestore; we have not tested that.

The mongo image for the tools does not update by itself. podman pull docker.io/library/mongo:9.0 gets its latest 9.0 release.

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=ferretdb -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
podman secret create --replace ferretdb-tls-cert ~/tls/server.crt
podman secret create --replace ferretdb-tls-key ~/tls/server.key
systemctl --user restart ferretdb

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

Troubleshooting

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

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

unable to get local issuer certificate. The client does not trust the certificate. Check that tlsCAFile points at the copy of ca.crt.

permission denied to create role. You used createUser in mongosh. Create logins with psql, as in step 5.

fe_sendauth: no password supplied. PostgreSQL asks for passwords inside the pod, which DocumentDB cannot give. Leave the image's own pg_hba.conf as it is.

Failed: stat /backup/...: permission denied from mongorestore. The mongo image runs commands that start with mongo as its own user, which cannot read ~/backup. Keep --entrypoint mongorestore, as in step 10.

mongorestore of a dump from MongoDB ends with Failed: ... createIndex.collation is not implemented yet. MongoDB 9.0 stores collation: { locale: "simple" } with every index, and FerretDB 2.7 does not take it. In our test, every document was restored, but no index except _id. Text indexes failed the same way, with Currently only textIndexVersion 2 is supported. Restore with --noIndexRestore added, then create the indexes again with createIndex in mongosh, without collation. db.collection.getIndexes() on MongoDB lists them.

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 6 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