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.1with 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
mongorestoreinto 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:
- a server set up as in the Podman guide, with ufw on as in the security guide;
- WireGuard on the server with the tunnel address
10.8.0.1, and each app server added as a device, as in Adding more devices. On an app server,AllowedIPs = 10.8.0.1/32sends only the database's traffic through the tunnel.
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.crtis the certificate authority's own certificate. It is not secret: your apps get a copy.ca.keysigns 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.crtandserver.keyare the database's certificate, valid forlocalhost,127.0.0.1and the tunnel address10.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:5432inside 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 asferretdb. FERRETDB_LISTEN_ADDR=-switches off the listener without encryption, andFERRETDB_LISTEN_TLSputs the TLS listener on port 27017, the port MongoDB clients expect.FERRETDB_TELEMETRY=disablestops the usage statistics and the version check.uid=1000lets 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_buffersis 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_sizereserves nothing. It tells the planner how much of the data is likely in memory, counting the file cache.work_memis 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_memspeeds upVACUUMand index builds.random_page_cost = 1.1tells the planner that random reads are cheap on SSD storage.ShmSizein 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 withStage $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
$jsonSchemavalidator 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 andmapReducefailed.$wherefailed too, as it does on MongoDB 9.0 by default. - Moving from MongoDB:
mongorestoreof 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.