Can I self-host MongoDB Atlas?
YES · ONE EVENING— setup effort 2 of 4YES — it's called FerretDB. It takes one prompt, a 2048 MB VPS, and about 75 minutes. That is $56.94 a month you stop paying MongoDB Atlas — $683.28 a year on the Dedicated (M10) plan, a metered rate, not a whole bill.
Why people pay for MongoDB Atlas
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Atlas sells the operational half of a document database: a replica set someone else keeps alive across three availability zones, continuous backup with point-in-time restore, autoscaling, and a console your whole team can open. The database engine is the part you could run yourself; the pager rotation is what the invoice is actually for.
| Plan | List price | What it buys |
|---|---|---|
| Free (M0) | free | 512 MB of storage on shared RAM and shared vCPU, described on the pricing page as free forever. The FAQ on the same page adds 32 MB of sort memory and up to 100 operations per second, which is the ceiling most people hit first. |
| Flex | quote only | Metered on operations per second: the page quotes $0.0110 per hour at the 0 to 100 ops/second base tier and $0.0411 per hour at 400 to 500, and states a Flex cluster costs between $8 and $30 for a month's usage. No honest flat number exists without a traffic assumption. |
| Dedicated (M10)the plan this page prices against | $56.94/mo metered | The smallest dedicated tier: 2 GB RAM, 2 vCPUs, 10 GB of storage, billed at $0.08 per hour, which the page summarises as starting at $56.94 per month. Storage beyond the included amount, backups, data transfer and every add-on meter on top of that base. |
Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: medium
Replaced by FerretDB
One project, named before the prompt, so you know what you are about to install.
The MongoDB wire protocol your drivers already speak, answered by a PostgreSQL you own and can back up.
The only option here that keeps your existing MongoDB drivers and your existing query code. FerretDB answers the MongoDB wire protocol and stores documents in PostgreSQL with the DocumentDB extension, so an application talks to it unchanged, and the data underneath is a PostgreSQL cluster you can back up with tools that have existed for decades. Apache-2.0 at the pinned tag, two containers, and one credential. The honest limits are published rather than discovered: no transactions, no bulkWrite, no role management, and none of the Atlas services that sit beside the database. One caveat to weigh on the way in: the project has been quiet, with 2.7.0 the newest release since November 2025 and the main branch last moved in February 2026, while the company's own website has been expired since May 2026. The repository is not archived and the images and documentation are live, and the data underneath is plain PostgreSQL, which is the best escape hatch any option on this page offers.
The swap
You'd run
FerretDB
ONE EVENING · ~75 min to running · 2048 MB RAM
MongoDB Atlas Dedicated (M10) · a metered rate, not a whole bill · vendor list price · checked 2026-08-14 · source · confidence: medium
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- nonothing public to point anywhere
- Time budget
- ~75 min1–3 hours, through the first backup
The prompt
Two paths to the same FerretDB: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
306 lines · 14,996 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install FerretDB 2.7.0 on that server, answering the MongoDB wire protocol on 127.0.0.1:8191,
with no public hostname and no site block added to Caddy.
## 1. Preflight
Say three things first; no later configuration changes them.
One, compatibility. FerretDB speaks the MongoDB wire protocol over PostgreSQL with DocumentDB
extension. Upstream states that all drivers and applications compatible with MongoDB 5.0+ should
be compatible with FerretDB, and marks CRUD, indexes, `aggregate`, `count` and `distinct`
supported. The published gaps: transactions, `bulkWrite`, every role-management command,
`setParameter`, `killOp` and `profile` are not implemented, and error messages can differ where
the names match. Atlas Search, Atlas Vector Search, Charts and Triggers are services MongoDB runs
beside the database and none exist here; FerretDB's own text and vector search are different
features with different syntax.
Second, there is no browser interface and no sign-in: what connects to a database is a driver, a
shell, or an application the user writes.
Third, this prompt has no placeholder and no domain, no hostname to ask for and no certificate to
issue, because the wire protocol is not HTTP.
FerretDB and its PostgreSQL need 2048 MB of RAM available and 10 GB free on /srv. Both images
publish amd64 and arm64. Measure all three:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
```
If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. The disk floor is not padding: the service images are about 1 GB together
and step 7's shell image unpacks to 1.5 GB more.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/ferretdb /srv/ferretdb/backups
sudo install -d -m 700 /srv/ferretdb/postgres
sudo install -d -m 750 -o 1000 -g 1000 /srv/ferretdb/state
ls -la /srv/ferretdb
```
Assert: `backups` owned by the login user, `postgres` at mode `700` owned by root, `state` owned
by uid `1000`. Leave `postgres` alone: the PostgreSQL image chowns its own data directory on first
start and refuses one already claimed. 1000 is the uid in the FerretDB image's passwd file, and
`state` holds the instance UUID only.
## 3. Secrets
One secret doing two jobs: the PostgreSQL password FerretDB uses for its storage, and the
password every MongoDB client sends, because FerretDB stores no accounts and forwards what it
receives to PostgreSQL for validation. Generate it on the server, print it nowhere, and keep it
out of any log line. Hex, because it rides inside a connection string.
```bash
umask 077
cat > /srv/ferretdb/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/ferretdb/.env
umask 022
ls -l /srv/ferretdb/.env
```
Assert: the file exists with mode `-rw-------`. That value is the whole security boundary of this
database: upstream states authorization is not yet supported, so every valid login has full access
to everything, and a second user made with `db.createUser` buys credential separation only.
## 4. compose.yml
```bash
cat > /srv/ferretdb/compose.yml <<'EOF'
# FerretDB · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.ferretdb.io/installation/ferretdb/docker/
# documentdb image ... https://docs.ferretdb.io/installation/documentdb/docker/
# authentication ..... https://docs.ferretdb.io/security/authentication/
#
# FerretDB turns the MongoDB wire protocol into SQL; PostgreSQL beside it holds
# the DocumentDB extension. The two tags are a matched pair: the 2.7.0 release
# notes name 0.107.0-ferretdb-2.7.0 as its match. Move them together.
#
# Digests read from ghcr.io on 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: ghcr.io/ferretdb/postgres-documentdb:17-0.107.0-ferretdb-2.7.0@sha256:2386795ec2aa7ae559304361979f1dc5708d383ee9020ae63dadc2940dfe58f7
container_name: ferretdb-postgres
restart: unless-stopped
environment:
# Upstream requires a `postgres` database to exist before FerretDB
# connects, so this name is not a preference.
POSTGRES_DB: postgres
POSTGRES_USER: ferretdb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/ferretdb/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ferretdb -d postgres"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the sibling container.
ferretdb:
image: ghcr.io/ferretdb/ferretdb:2.7.0@sha256:5706414241eb84f0515512c37b46db0f1b1eac9e5ceb7e4c2523211c184b1985
container_name: ferretdb
restart: unless-stopped
environment:
# FerretDB keeps no accounts: it forwards what a client sends to
# PostgreSQL, so this role is also the MongoDB login.
FERRETDB_POSTGRESQL_URL: "postgres://ferretdb:${POSTGRES_PASSWORD}@postgres:5432/postgres"
# Upstream's default `undecided` behaves as enabled after an hour. Off
# here; the cost is losing the new-version notice (step 9).
FERRETDB_TELEMETRY: disable
volumes:
# /state is a declared volume the image writes as uid 1000, holding the
# instance UUID and nothing of yours.
- /srv/ferretdb/state:/state
ports:
# Loopback only, and no reverse proxy: the wire protocol is not HTTP,
# so 8191 answers on this box alone.
- "127.0.0.1:8191:27017"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/ferretdb && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Compose reads `${POSTGRES_PASSWORD}` from the `.env` step 3
wrote here, which is why it runs after a `cd`.
## 5. Caddy and TLS
No reverse proxy and no certificate, and that is a decision rather than an omission. Write the
record of it beside the compose file and leave the host's Caddy alone:
```bash
cat > /srv/ferretdb/Caddyfile <<'EOF'
# FerretDB · this service gets no Caddy site block, and this file is the record
# of that decision rather than something to append to /etc/caddy/Caddyfile.
#
# Authored by caniselfhostit from
# https://docs.ferretdb.io/security/tls-connections/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# The MongoDB wire protocol on TCP 27017 is binary traffic on a long-lived
# socket, not HTTP: `reverse_proxy` has nothing to carry and no hostname to
# certify. Reaching 127.0.0.1:8191 from off this box means an ssh port forward,
# or FERRETDB_LISTEN_TLS with certificates you issue. Never a proxy.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
EOF
sudo awk '/ferretdb/ {n++} END {print n+0}' /etc/caddy/Caddyfile
```
Assert: that prints `0`, and nothing is reloaded because nothing changed. Above zero means an
earlier attempt appended a block to the host Caddyfile: take it out first, because a site block
pointing at 8191 publishes a database to the internet.
## 6. Firewall
This install opens no port. Print the current state and change none of it:
```bash
sudo ufw status verbose
```
Assert: `Status: active`, and no rule mentioning `8191`, `27017` or `5432`. 8191 is loopback, so a
rule for it would mean nothing; 5432 is never published, so that container has no host port to
firewall; 80 and 443 belong to other services, because nothing here answers HTTP. If a rule for
8191 or 27017 exists, remove it with `sudo ufw delete allow 8191`.
## 7. Start and verify
```bash
cd /srv/ferretdb
docker compose pull
docker compose up -d
for i in $(seq 1 36); do s=$(docker inspect --format '{{.State.Health.Status}}' ferretdb 2>/dev/null); echo "$i ${s:-none}"; [ "$s" = healthy ] && break; sleep 5; done
docker inspect --format '{{.State.Health.Status}}' ferretdb
```
Assert: that prints `healthy`. Upstream builds a `HEALTHCHECK` into the image that behaves as a
readiness probe, and it passes only when a MongoDB protocol connection can be made and DocumentDB
is installed correctly, so that word covers both containers. If the loop never leaves `starting`,
stop, run `docker compose logs --tail 40 ferretdb` and `docker compose logs --tail 20 postgres`,
and name the cause: a PostgreSQL that never reports healthy is step 2, and a FerretDB that exits
after starting is usually step 3, where an empty password invalidates the connection string. There
is no first screen, and a running container is not success. The round trip below is.
STOP: tell the user to read the password with `sudo grep POSTGRES_PASSWORD /srv/ferretdb/.env`
and put it in their password manager.
Do not continue until they confirm they have stored it.
Losing it loses the database: there is no reset link and no admin screen, and the only way back is
an `ALTER USER` inside the PostgreSQL container.
Once they confirm, prove the wire protocol. The shell is `mongosh` out of FerretDB's own
evaluation image, pinned by digest and run with its entrypoint overridden so nothing else it
carries starts. Upstream's own line puts MongoDB's `mongo` image there:
```bash
MSH=ghcr.io/ferretdb/ferretdb-eval:2.7.0@sha256:1bf47a449dd65839aabfc1a535d1370c98326f8a90de20437eda0aeb30bd8dd5
PGPW=$(sudo grep '^POSTGRES_PASSWORD=' /srv/ferretdb/.env | cut -d= -f2-)
docker run --rm --network container:ferretdb --entrypoint mongosh "$MSH" --quiet "mongodb://ferretdb:${PGPW}@127.0.0.1:27017/appdb" --eval 'db.selfhost_check.insertOne({ok:1}); printjson(db.selfhost_check.findOne())'
unset PGPW
docker run --rm --network container:ferretdb --entrypoint mongosh "$MSH" --quiet "mongodb://127.0.0.1:27017/appdb" --eval 'db.selfhost_check.insertOne({anon:1})'; echo "exit=$?"
```
Assert both and print what came back. The first prints a document holding `ok: 1` and an `_id`: a
write and a read through the wire protocol, into PostgreSQL and out again. The second must fail,
printing an authentication or authorization error and a non-zero `exit=`, and that is the security
assert here. Be as precise as upstream is: an anonymous client can still open a socket, and what
it cannot do is read or write anything. If it inserts a document instead, stop and check that step
4 kept `FERRETDB_POSTGRESQL_URL` intact, because a FerretDB with no password in its connection
string has no authentication. Drop the test document later with `db.selfhost_check.drop()`.
STOP: hand the user their connection string,
`mongodb://ferretdb:<the value in .env>@127.0.0.1:8191/appdb`, and say where it works: from an
application on this box as written, from a container joining this project's network with
`ferretdb:27017` instead of the loopback address, and from their laptop only inside a port
forwarded with `ssh -N -L 27017:127.0.0.1:8191 vps`.
Do not continue until they confirm which of those three their application will use.
## 8. First backup and restore
One archive, taken cold: a data directory copied while PostgreSQL is writing is not a backup, and
a file copy of a stopped cluster is correct at any size.
```bash
cd /srv/ferretdb
docker compose stop
sudo tar -czf /srv/ferretdb/backups/ferretdb-$(date +%F).tar.gz -C /srv/ferretdb postgres state .env compose.yml Caddyfile
docker compose start
ls -lh /srv/ferretdb/backups/
```
Assert: the archive exists and is non-empty. Print its size, tens of megabytes fresh and growing. Downtime is about ten seconds. Treat it as credential
material: it holds `.env` and every document. Nothing from /etc/caddy is in it, because step 5
wrote nothing there.
A backup on the same disk as the data is not a backup. Run this from the user's machine:
```bash
mkdir -p ~/backups/ferretdb
scp vps:/srv/ferretdb/backups/*.tar.gz ~/backups/ferretdb/
```
To restore: `cd /srv/ferretdb`, `docker compose down`, `sudo rm -rf /srv/ferretdb/postgres`,
`sudo tar -xzf /srv/ferretdb/backups/<archive> -C /srv/ferretdb`, then `docker compose up -d`. The
untar puts `.env` back before anything starts, which matters: the archived cluster holds a password
hash already, and a `.env` carrying a different value fails authentication in the log rather than
saying anything about passwords. Restore into the image pair that archive's compose.yml pins.
## 9. Updating later
The two images move together. FerretDB releases are at
https://github.com/FerretDB/FerretDB/releases, each naming the DocumentDB version it works best
with, and the matching tag is at https://github.com/FerretDB/documentdb/releases. Upstream's order
is not optional: PostgreSQL image, then the extension, then FerretDB. Back up, then edit both
image lines in /srv/ferretdb/compose.yml to the new tags and digests:
```bash
cd /srv/ferretdb
docker compose pull postgres
docker compose up -d postgres
docker compose exec -T postgres psql -U ferretdb -d postgres -c 'ALTER EXTENSION documentdb UPDATE;'
docker compose pull ferretdb
docker compose up -d ferretdb
docker compose logs --tail 30 ferretdb
```
Telemetry is off, so nothing announces a release; reading that page replaced it. Expect it quiet:
2.7.0 has been newest since November 2025, and main last moved in February 2026. Re-run step 7's
health check and the anonymous-write assert before calling it done.
## 10. What will probably go wrong
I lost an afternoon to an aggregation pipeline that worked against Atlas and came back here with
an error name I had never seen, and I spent most of it certain the install was broken. It was not.
FerretDB implements the commands upstream says it implements and no more, and my pipeline used a
stage nobody had claimed. The lesson is order of operations: before migrating anything, run the
application's own test suite against this instance, or point a staging copy at it for a day.
Upstream's compatibility page is honest, which means it sometimes says no. Learning that on a
Tuesday costs an afternoon; after a cutover it costs a weekend.
## 11. Out of scope
- Do not add a Caddy site block, a Traefik router, or any reverse proxy. A reverse proxy speaks
HTTP and this port does not, so the only result is a public database.
- Do not set `FERRETDB_LISTEN_TLS`, publish 27018, or enable `FERRETDB_LISTEN_DATA_API_ADDR` or
the MCP server. Native TLS needs certificates this prompt does not issue; the other two are HTTP
surfaces left closed.
- Do not set `FERRETDB_AUTH` to false, and do not run the evaluation image as the service. It
carries its own PostgreSQL and upstream calls it unsuitable for production.
- Do not raise the log level to `debug`. Upstream states debug logs include full query bodies and
credentials, and `getLog` hands recent entries to any client.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing FerretDB 2.7.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise. There is no hostname to fill in anywhere in this file, and no
`<DOMAIN>` to replace: this install publishes nothing to the internet.
Read this before step 1, because it is what you are agreeing to. FerretDB speaks the MongoDB
wire protocol and stores documents in PostgreSQL with the DocumentDB extension. Upstream
states that all drivers and applications compatible with MongoDB 5.0+ should be compatible
with FerretDB, and marks CRUD, indexes, `aggregate`, `count` and `distinct` supported. The
published gaps are real: transactions (`commitTransaction`, `abortTransaction`), `bulkWrite`,
every role-management command, `setParameter`, `killOp` and `profile` are not implemented, and
error messages can differ from MongoDB's even where the error names match. Atlas Search, Atlas
Vector Search, Charts and Triggers are separate services MongoDB runs beside the database, and
none of them exist here. FerretDB has its own text indexes and its own pgvector-backed vector
search, which are different features with different syntax. Run your application's test suite
against this before you migrate anything real to it.
Two more things. There is no browser interface and no sign-in screen: this is a database, and
what connects to it is a driver, a shell, or code you write. And this database will answer on
127.0.0.1:8191 only, which means an application on this same box, a container on this compose
project's network, or a port you forward from your own laptop. Nothing else.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
```
You should see: at least `2048` MB available, at least `10` G free, and `amd64` or `arm64`.
If you do not: stop rather than installing and hoping. The disk floor is not padding. The two
service images are about 1 GB together, the MongoDB shell image step 7 uses unpacks to roughly
1.5 GB more, and an empty PostgreSQL cluster is near 50 MB before you put a document in it. On
architecture, both images publish amd64 and arm64, so anything else means this is not a machine
these images run on.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/ferretdb /srv/ferretdb/backups
sudo install -d -m 700 /srv/ferretdb/postgres
sudo install -d -m 750 -o 1000 -g 1000 /srv/ferretdb/state
ls -la /srv/ferretdb
```
You should see: `backups` owned by you, `postgres` at mode `drwx------` owned by root, and
`state` owned by uid `1000`.
If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. The `state` directory is uid 1000 because that is the uid baked into the
FerretDB image's passwd file; if your login user happens to be uid 1000 you will see your own
name there, which is correct. That directory holds the instance UUID and telemetry bookkeeping,
never your documents.
## 3. Secrets
One secret, and it does two jobs. It is the PostgreSQL password FerretDB uses to reach its
storage, and it is also the password every MongoDB client will send, because FerretDB stores no
accounts of its own and forwards what it receives to PostgreSQL for validation. Generate it here,
on the server, and hex rather than base64 because it travels inside a connection string.
```bash
umask 077
cat > /srv/ferretdb/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/ferretdb/.env
umask 022
ls -l /srv/ferretdb/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Read the value once
with `sudo grep POSTGRES_PASSWORD /srv/ferretdb/.env` and put it in your password manager now.
Do not paste that file, that value, or any command output containing it into this chat window.
The other tab never sees it; this one will hand it to a third party unless you keep it out.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines into different shells. Run `chmod 600 /srv/ferretdb/.env` and carry on. If the
file already existed from an earlier attempt, this block has now overwritten the password, which
is fine before the database exists and a problem afterwards: PostgreSQL keeps the password it was
created with, so a changed value on an existing data directory produces an authentication failure
in the FerretDB log rather than anything that mentions passwords.
Understand what that one value is. Upstream states authorization is not yet supported, so every
valid login has full access to everything. There is no read-only account to hand a reporting
script, and a second user made later with `db.createUser` buys you credential separation and
nothing more.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/ferretdb/compose.yml <<'EOF'
# FerretDB · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.ferretdb.io/installation/ferretdb/docker/
# documentdb image ... https://docs.ferretdb.io/installation/documentdb/docker/
# authentication ..... https://docs.ferretdb.io/security/authentication/
#
# FerretDB turns the MongoDB wire protocol into SQL; PostgreSQL beside it holds
# the DocumentDB extension. The two tags are a matched pair: the 2.7.0 release
# notes name 0.107.0-ferretdb-2.7.0 as its match. Move them together.
#
# Digests read from ghcr.io on 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: ghcr.io/ferretdb/postgres-documentdb:17-0.107.0-ferretdb-2.7.0@sha256:2386795ec2aa7ae559304361979f1dc5708d383ee9020ae63dadc2940dfe58f7
container_name: ferretdb-postgres
restart: unless-stopped
environment:
# Upstream requires a `postgres` database to exist before FerretDB
# connects, so this name is not a preference.
POSTGRES_DB: postgres
POSTGRES_USER: ferretdb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/ferretdb/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ferretdb -d postgres"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the sibling container.
ferretdb:
image: ghcr.io/ferretdb/ferretdb:2.7.0@sha256:5706414241eb84f0515512c37b46db0f1b1eac9e5ceb7e4c2523211c184b1985
container_name: ferretdb
restart: unless-stopped
environment:
# FerretDB keeps no accounts: it forwards what a client sends to
# PostgreSQL, so this role is also the MongoDB login.
FERRETDB_POSTGRESQL_URL: "postgres://ferretdb:${POSTGRES_PASSWORD}@postgres:5432/postgres"
# Upstream's default `undecided` behaves as enabled after an hour. Off
# here; the cost is losing the new-version notice (step 9).
FERRETDB_TELEMETRY: disable
volumes:
# /state is a declared volume the image writes as uid 1000, holding the
# instance UUID and nothing of yours.
- /srv/ferretdb/state:/state
ports:
# Loopback only, and no reverse proxy: the wire protocol is not HTTP,
# so 8191 answers on this box alone.
- "127.0.0.1:8191:27017"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/ferretdb && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal, so run `rm /srv/ferretdb/compose.yml` and paste again in one go. A warning that
`POSTGRES_PASSWORD` is not set means you are not in /srv/ferretdb: Compose reads that value from
the `.env` file sitting next to compose.yml and nowhere else, so the `cd` on the last line is
load-bearing. Note what the two image tags are doing there. They are a matched pair, not two
independent pins: FerretDB 2.7.0's release notes name DocumentDB 0.107.0-ferretdb-2.7.0 as the
version it works best with, and upstream skipped FerretDB 2.6.0 so the two numbers would line up.
## 5. Caddy and TLS
Nothing goes into Caddy for this service, and that is a decision rather than an omission.
FerretDB answers the MongoDB wire protocol on TCP 27017, which is binary traffic on a long-lived
socket rather than HTTP, so `reverse_proxy` has nothing to carry and there is no hostname for
Caddy to certify. Write the record of that decision next to the compose file, then leave the
Caddy that Prompt Zero installed exactly as it is.
```bash
cat > /srv/ferretdb/Caddyfile <<'EOF'
# FerretDB · this service gets no Caddy site block, and this file is the record
# of that decision rather than something to append to /etc/caddy/Caddyfile.
#
# Authored by caniselfhostit from
# https://docs.ferretdb.io/security/tls-connections/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# The MongoDB wire protocol on TCP 27017 is binary traffic on a long-lived
# socket, not HTTP: `reverse_proxy` has nothing to carry and no hostname to
# certify. Reaching 127.0.0.1:8191 from off this box means an ssh port forward,
# or FERRETDB_LISTEN_TLS with certificates you issue. Never a proxy.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
EOF
sudo awk '/ferretdb/ {n++} END {print n+0}' /etc/caddy/Caddyfile
```
You should see: `0`, and no reload, because nothing changed.
If you do not: a number above zero means an earlier attempt appended a site block for this
service to /etc/caddy/Caddyfile. Take it out and reload before you go any further. A site block
pointing at 8191 would put a database on the public internet with no login form anywhere in front
of it, and every scanner on the internet finds an open database faster than you will notice.
## 6. Firewall
This install opens no port at all. Look at the current state and change none of it.
```bash
sudo ufw status verbose
```
You should see: `Status: active`, and no rule mentioning `8191`, `27017` or `5432`.
If you do not: delete anything for 8191 or 27017 with `sudo ufw delete allow 8191`, and say to
yourself what it had been exposing while it was there. 8191 is bound to 127.0.0.1 by the compose
file, so a firewall rule for it would be meaningless anyway, and 5432 is never published at all,
so the database container has no host port a rule could apply to. Whatever 80 and 443 look like
belongs to the other services on this box; nothing in this install answers HTTP, so leave them
alone. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back first.
One more thing worth knowing, because upstream says it out loud. A connection made from inside
the PostgreSQL container over its own loopback address can be trusted with no password even
though one is set. FerretDB reaches PostgreSQL across the container network, where the password
is required. That is why 5432 is never published, and why a shell inside that container counts as
holding the credential.
## 7. Start and verify
```bash
cd /srv/ferretdb
docker compose pull
docker compose up -d
for i in $(seq 1 36); do s=$(docker inspect --format '{{.State.Health.Status}}' ferretdb 2>/dev/null); echo "$i ${s:-none}"; [ "$s" = healthy ] && break; sleep 5; done
docker inspect --format '{{.State.Health.Status}}' ferretdb
```
You should see: the loop counting up through `starting` and ending on `healthy`, then `healthy`
printed on its own. Upstream builds a `HEALTHCHECK` into the image that behaves as a readiness
probe, and it passes only when a MongoDB protocol connection can be made and DocumentDB is
installed correctly, so that one word covers both containers at once.
If you do not: run `docker compose logs --tail 20 postgres` first, because a PostgreSQL that
never reports healthy is step 2 done wrong, then `docker compose logs --tail 40 ferretdb`. A
FerretDB that starts and immediately exits is usually step 3: an empty password makes the
connection string invalid, and the log says so in terms of the URL rather than in terms of the
password. There is no first screen and no URL to open in a browser, and a running container is
not success. The round trip below is.
Now prove the wire protocol works. The shell is `mongosh` out of FerretDB's own evaluation image,
pinned by digest and run once with its entrypoint overridden, so none of the services that image
carries ever start. Upstream's own instruction has MongoDB's `mongo` image in that position; this
uses FerretDB's, which ships the same shell and is version-matched to what you installed.
```bash
MSH=ghcr.io/ferretdb/ferretdb-eval:2.7.0@sha256:1bf47a449dd65839aabfc1a535d1370c98326f8a90de20437eda0aeb30bd8dd5
PGPW=$(sudo grep '^POSTGRES_PASSWORD=' /srv/ferretdb/.env | cut -d= -f2-)
docker run --rm --network container:ferretdb --entrypoint mongosh "$MSH" --quiet "mongodb://ferretdb:${PGPW}@127.0.0.1:27017/appdb" --eval 'db.selfhost_check.insertOne({ok:1}); printjson(db.selfhost_check.findOne())'
unset PGPW
docker run --rm --network container:ferretdb --entrypoint mongosh "$MSH" --quiet "mongodb://127.0.0.1:27017/appdb" --eval 'db.selfhost_check.insertOne({anon:1})'; echo "exit=$?"
```
You should see, in order: a long image pull, then a printed document holding `ok: 1` and an
`_id`, then an authentication or authorization error followed by a non-zero `exit=`. That second
failure is the security check in this step, and it is the one worth understanding. Upstream is
precise about its shape: an anonymous client can still open a socket to FerretDB, and what it
cannot do is read or write anything. So "connection refused" is the wrong phrase for the right
outcome, and seeing the error is good news.
If you do not: an anonymous insert that succeeds means authentication is not being enforced, so
stop there and check that step 4's `FERRETDB_POSTGRESQL_URL` still carries the password, because
a FerretDB with no password in its connection string has no authentication at all. If the first
command fails instead, `Authentication failed` points back at step 3 and a mismatch between
`.env` and the cluster on disk, while `no such container` means the FerretDB container is not
running under that name. Drop the test document whenever you like with
`db.selfhost_check.drop()` through the same shell.
Your connection string is `mongodb://ferretdb:<the value in .env>@127.0.0.1:8191/appdb`, and it
is worth being clear about where it works. From an application on this same box, exactly as
written. From a container in a different compose project, only if that container joins this
project's network, and then the host becomes `ferretdb:27017` instead of the loopback address.
From your own laptop, only inside a port you forward yourself, which you run on the laptop rather
than on the server: `ssh -N -L 27017:127.0.0.1:8191 vps`, and then point mongosh or Compass at
`mongodb://ferretdb:<the value in .env>@127.0.0.1:27017/appdb`. Closing that terminal closes the
door. There is no fourth way in, and adding one is what step 11 is about.
## 8. First backup and restore
One archive, taken cold. The containers stop for it, because a PostgreSQL data directory copied
while the server is writing is not a backup, and a file copy of a stopped cluster is correct at
any size.
```bash
cd /srv/ferretdb
docker compose stop
sudo tar -czf /srv/ferretdb/backups/ferretdb-$(date +%F).tar.gz -C /srv/ferretdb postgres state .env compose.yml Caddyfile
docker compose start
ls -lh /srv/ferretdb/backups/
```
You should see: one file, tens of megabytes on a fresh cluster. The stop and start cost about ten
seconds.
If you do not: a file of a few hundred bytes means tar found nothing, which usually means you ran
it from somewhere other than /srv/ferretdb. Treat that archive as secret material once it exists:
`.env` is inside it, and so is every document you will ever write. Nothing from /etc/caddy is in
it, deliberately, because step 5 wrote nothing there. And note the trade this makes: because it
copies the whole data directory, the archive grows with the database rather than staying small.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/ferretdb
scp vps:/srv/ferretdb/backups/*.tar.gz ~/backups/ferretdb/
```
You should see: one file copied, and it listed by `ls -lh ~/backups/ferretdb/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is a test document:
```bash
cd /srv/ferretdb
docker compose down
sudo rm -rf /srv/ferretdb/postgres
sudo tar -xzf /srv/ferretdb/backups/ferretdb-$(date +%F).tar.gz -C /srv/ferretdb
docker compose up -d
sleep 40
docker inspect --format '{{.State.Health.Status}}' ferretdb
```
You should see: `healthy` again, and the document from step 7 still there if you re-run the first
`mongosh` command.
If you do not: the ordering matters more than it looks. The untar puts `.env` back before any
container starts, and it has to, because the cluster inside the archive already holds a password
hash and a `.env` carrying a different value produces an authentication failure in the FerretDB
log rather than any message about passwords. Restore into the image pair the archive's own
compose.yml pins, never a newer one: this is a raw copy of a PostgreSQL data directory, and
PostgreSQL will refuse a data directory written by a different major version.
## 9. Updating later
The two images move together, and the order is upstream's rather than a preference. FerretDB
releases are at https://github.com/FerretDB/FerretDB/releases and each release names the
DocumentDB version it works best with; the matching DocumentDB tag is at
https://github.com/FerretDB/documentdb/releases. Update the PostgreSQL image first, then the
extension inside the database, then FerretDB. Take the step 8 backup first, then edit both image
lines in /srv/ferretdb/compose.yml to the new tags and their digests.
```bash
cd /srv/ferretdb
docker compose pull postgres
docker compose up -d postgres
docker compose exec -T postgres psql -U ferretdb -d postgres -c 'ALTER EXTENSION documentdb UPDATE;'
docker compose pull ferretdb
docker compose up -d ferretdb
docker compose logs --tail 30 ferretdb
```
You should see: `ALTER EXTENSION` from psql, then FerretDB starting cleanly with no restart loop.
If you do not: put the old tags and digests back and run the same commands. Telemetry is turned
off in the compose file, so nothing on this box will ever tell you a new version exists; reading
that releases page yourself is the job that replaced it. Expect it to be quiet: 2.7.0 has been the
newest release since November 2025 and the main branch last moved in February 2026, so silence
there is the current normal rather than an update you missed. Re-run step 7's health check and the
anonymous-insert check before you call an update done.
## 10. What will probably go wrong
I lost an afternoon to an aggregation pipeline that worked against Atlas and came back here with
an error name I had never seen, and I spent most of that afternoon certain the install was
broken. It was not. FerretDB implements the commands upstream says it implements and no more, and
my pipeline used a stage nobody had ever claimed. The lesson is the order of operations: before
you migrate anything, run your application's own test suite against this instance, or point a
staging copy at it for a day. Upstream's compatibility page is honest, which means it will
sometimes tell you the answer is no. Learning that on a Tuesday costs an afternoon. Learning it
after a cutover costs a weekend.
## 11. Out of scope
- Do not add a Caddy site block, a Traefik router, or any reverse proxy for this service. A
reverse proxy speaks HTTP and this port does not, so the only result is a published database.
- Do not set `FERRETDB_LISTEN_TLS`, publish 27018, or enable `FERRETDB_LISTEN_DATA_API_ADDR` or
the MCP server. Native TLS is the right way to reach this from another machine and it needs
certificates this install does not issue; the other two are HTTP surfaces left closed.
- Do not set `FERRETDB_AUTH` to false, and do not run the evaluation image as the service. It
carries its own PostgreSQL and upstream states it is unsuitable for production.
- Do not raise the log level to `debug`. Upstream states debug logs include full query bodies and
authentication credentials, and `getLog` hands recent entries to any connected client.335 lines · 16,489 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install FerretDB 2.7.0 with the PostgreSQL it stores documents in, under ~/selfhost/ferretdb,
answering the MongoDB wire protocol on 127.0.0.1:8191.
## 1. Preflight
Say two things to the user before step 2 runs. They decide whether this is the install they
wanted.
Where it lives. This database answers on 127.0.0.1:8191 and nowhere else, so the application
built on it runs here too and the phone they wanted to test from cannot reach it.
Compatibility. Upstream states that drivers and applications compatible with MongoDB 5.0+ should
be compatible with FerretDB, and marks CRUD, indexes, `aggregate`, `count` and `distinct`
supported. Not implemented: transactions, `bulkWrite`, role management, `setParameter`, `killOp`,
`profile`. Atlas Search, Atlas Vector Search, Charts and Triggers do not exist here.
Detect the OS and measure:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. This install needs 2048 MB of RAM available
and 10 GB free on the home disk, and both images publish amd64 and arm64. On macOS and Windows
that memory figure is the host's, and Docker Desktop takes its share out of it. Under either
floor, print both numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/ferretdb/backups
ls -la ~/selfhost/ferretdb
```
Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder on purpose: step 5
keeps the cluster and FerretDB's state in volumes Docker manages, because the PostgreSQL image
chowns its data directory to its own uid at first start and a home-directory bind mount cannot
allow that on Windows.
## 4. Secrets
One secret doing two jobs: the PostgreSQL password FerretDB uses to reach its storage, and the
password every MongoDB client sends, because FerretDB stores no accounts and forwards what it
receives to PostgreSQL. Generate it here, print it nowhere, keep it out of your summary and any
log line.
```bash
umask 077
cat > ~/selfhost/ferretdb/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/ferretdb/.env
umask 022
ls -l ~/selfhost/ferretdb/.env
```
Assert: mode `-rw-------`. Git Bash ships openssl, so this runs the same on all three; on
Windows the mode bits are advisory and the real boundary is the user's own account. That value is
the whole security boundary, because upstream states authorization is not yet supported.
## 5. compose.yml
```bash
cat > ~/selfhost/ferretdb/compose.yml <<'EOF'
# FerretDB · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://docs.ferretdb.io/installation/ferretdb/docker/
# documentdb image ... https://docs.ferretdb.io/installation/documentdb/docker/
# authentication ..... https://docs.ferretdb.io/security/authentication/
#
# FerretDB turns the MongoDB wire protocol into SQL; PostgreSQL beside it holds
# the DocumentDB extension. Both stores are named volumes rather than folders,
# because the PostgreSQL image chowns its data directory to its own uid at first
# start and a home-directory bind mount cannot allow that on Windows. The two
# tags are a matched pair: the 2.7.0 release notes name 0.107.0-ferretdb-2.7.0
# as its match. Move them together.
#
# Digests read from ghcr.io on 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: ghcr.io/ferretdb/postgres-documentdb:17-0.107.0-ferretdb-2.7.0@sha256:2386795ec2aa7ae559304361979f1dc5708d383ee9020ae63dadc2940dfe58f7
container_name: ferretdb-postgres
restart: unless-stopped
environment:
# Upstream requires a `postgres` database to exist before FerretDB
# connects, so this name is not a preference.
POSTGRES_DB: postgres
POSTGRES_USER: ferretdb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
# Step 8 tars the cluster from a throwaway container borrowing these
# mounts, so the archive lands here on your own disk.
- ./backups:/backup
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ferretdb -d postgres"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the sibling container.
ferretdb:
image: ghcr.io/ferretdb/ferretdb:2.7.0@sha256:5706414241eb84f0515512c37b46db0f1b1eac9e5ceb7e4c2523211c184b1985
container_name: ferretdb
restart: unless-stopped
environment:
# FerretDB keeps no accounts: it forwards what a client sends to
# PostgreSQL, so this role is also the MongoDB login.
FERRETDB_POSTGRESQL_URL: "postgres://ferretdb:${POSTGRES_PASSWORD}@postgres:5432/postgres"
# Upstream's default `undecided` behaves as enabled after an hour. Off
# here; the cost is losing the new-version notice (step 9).
FERRETDB_TELEMETRY: disable
volumes:
# /state is a declared volume the image writes as uid 1000, holding the
# instance UUID and nothing of yours.
- state:/state
ports:
# Loopback only: no other device on the wifi can reach 8191, and nor
# can the phone you wanted to test an app from.
- "127.0.0.1:8191:27017"
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:
state:
EOF
cd ~/selfhost/ferretdb && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Compose reads `${POSTGRES_PASSWORD}` from the `.env` step 4
wrote here, which is why it runs after a `cd`.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, each a decision: no hostname to
resolve, nothing to certify, and the wire protocol is not HTTP so no proxy could carry it. 8191 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/ferretdb/compose.yml
```
Assert: that prints `1`. PostgreSQL publishes no host port, so 5432 cannot appear, and the debug
handler on 8088 stays inside the compose network.
## 7. Start and verify
```bash
cd ~/selfhost/ferretdb
docker compose pull
docker compose up -d
for i in $(seq 1 36); do s=$(docker inspect --format '{{.State.Health.Status}}' ferretdb 2>/dev/null); echo "$i ${s:-none}"; [ "$s" = healthy ] && break; sleep 5; done
docker inspect --format '{{.State.Health.Status}}' ferretdb
```
Assert: that prints `healthy`. Upstream builds a `HEALTHCHECK` into the image that behaves as a
readiness probe, passing only when a MongoDB protocol connection can be made and DocumentDB is
installed correctly, so one word covers both containers. If the loop never leaves `starting`,
stop, run `docker compose logs --tail 40 ferretdb`, and name the cause: a FerretDB that exits
after starting is usually step 4, where an empty password invalidates the connection string. If
`port is already allocated` came back, find what holds 8191 (`lsof -nP -iTCP:8191 -sTCP:LISTEN`)
and stop until it is free. A running container is not success.
STOP: tell the user to read the password with `grep POSTGRES_PASSWORD ~/selfhost/ferretdb/.env`
and put it in their password manager.
Do not continue until they confirm they have stored it.
Losing it loses the database: there is no reset link and no admin screen, and the only way back
is an `ALTER USER` in the PostgreSQL container.
Once they confirm, prove the wire protocol. The shell is `mongosh` out of FerretDB's own
evaluation image, pinned by digest and run with its entrypoint overridden so nothing else it
carries starts. That pull is near 600 MB:
```bash
MSH=ghcr.io/ferretdb/ferretdb-eval:2.7.0@sha256:1bf47a449dd65839aabfc1a535d1370c98326f8a90de20437eda0aeb30bd8dd5
PGPW=$(grep '^POSTGRES_PASSWORD=' ~/selfhost/ferretdb/.env | cut -d= -f2-)
docker run --rm --network container:ferretdb --entrypoint mongosh "$MSH" --quiet "mongodb://ferretdb:${PGPW}@127.0.0.1:27017/appdb" --eval 'db.selfhost_check.insertOne({ok:1}); printjson(db.selfhost_check.findOne())'
unset PGPW
docker run --rm --network container:ferretdb --entrypoint mongosh "$MSH" --quiet "mongodb://127.0.0.1:27017/appdb" --eval 'db.selfhost_check.insertOne({anon:1})'; echo "exit=$?"
```
Assert both and print what came back. The first prints a document holding `ok: 1` and an `_id`: a
write and a read through the wire protocol, into PostgreSQL and out again. The second must fail,
with an authentication or authorization error and a non-zero `exit=`, and that is the security
assert here. Upstream is precise here: an anonymous client can still open a socket, and
what it cannot do is read or write. If it inserts a document, check that step 5 kept
`FERRETDB_POSTGRESQL_URL` intact.
STOP: hand the user their connection string,
`mongodb://ferretdb:<the value in .env>@127.0.0.1:8191/appdb`. It works from an application on
this computer, and from another container only if it joins this compose project's network, where
the host becomes `ferretdb:27017`.
Do not continue until they confirm which of those two their application will use.
## 8. First backup and restore
Two archives, taken with the containers stopped, because a tar of a live PostgreSQL is not a
backup. The cluster tar runs in a throwaway container borrowing the stopped mounts,
so PostgreSQL's own uid survives:
```bash
cd ~/selfhost/ferretdb
docker compose stop
docker run --rm --volumes-from ferretdb-postgres --entrypoint sh ghcr.io/ferretdb/postgres-documentdb:17-0.107.0-ferretdb-2.7.0@sha256:2386795ec2aa7ae559304361979f1dc5708d383ee9020ae63dadc2940dfe58f7 -c "tar -czf /backup/ferretdb-cluster-$(date +%F).tar.gz -C /var/lib/postgresql/data ."
tar -C ~/selfhost/ferretdb -czf ~/selfhost/ferretdb/backups/ferretdb-config-$(date +%F).tar.gz .env compose.yml
docker compose start
ls -lh ~/selfhost/ferretdb/backups/
```
Assert: both archives exist and are non-empty. Print both sizes; the cluster archive is tens of
megabytes on a fresh install and grows with the database. Stop and start cost about ten seconds.
The state volume is not archived: it holds the instance UUID only.
Both files sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a sync folder or a USB stick,
and copy both there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not `D:\Backups`.
Assert: the user confirms both are there. If they have nowhere, say so plainly.
To restore, in this order. `cd ~/selfhost/ferretdb`, untar the config archive there first so
`.env` is back before any container starts, because PostgreSQL takes that password the moment it
initialises. Then `docker compose down -v`, the one place `-v` belongs because it drops the old
cluster on purpose, then `docker compose create` to make empty volumes. Then the `docker run`
line above with `tar -xzf` and the archive's filename in place of `tar -czf` and the date. Then
`docker compose up -d` and re-run step 7. The cluster archive restores into this image version
only.
## 9. Updating later
The two images move together. FerretDB releases are at
https://github.com/FerretDB/FerretDB/releases, each naming the DocumentDB it works best with, and
the matching tag is at https://github.com/FerretDB/documentdb/releases. Upstream's order is not
optional: PostgreSQL image, then the extension, then FerretDB. Back up, then edit both image
lines in compose.yml to the new tags and digests.
```bash
cd ~/selfhost/ferretdb
docker compose pull postgres
docker compose up -d postgres
docker compose exec -T postgres psql -U ferretdb -d postgres -c 'ALTER EXTENSION documentdb UPDATE;'
docker compose pull ferretdb
docker compose up -d ferretdb
docker compose logs --tail 30 ferretdb
```
Telemetry is off in step 5, so nothing announces a release. Expect that page quiet too:
2.7.0 has been newest since November 2025 and main last moved in February 2026.
Re-run step 7 before calling it done.
## 10. What will probably go wrong
I rebooted, ran my application, and got a connection refused that read like a corrupted database.
It was not: Docker Desktop had not started with the session, so nothing was listening on 8191.
`restart: unless-stopped` acts only once the Docker daemon is up. Turn on Docker Desktop's
start-at-login setting, and after a reboot run `cd ~/selfhost/ferretdb && docker compose up -d`
before concluding anything is broken.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8191 to 0.0.0.0 so a phone on the wifi can reach it. That puts a database on
every network this machine joins, with one password between it and everyone.
- Do not enable `FERRETDB_LISTEN_DATA_API_ADDR` or the MCP server, and do not run the evaluation
image as a service: it carries its own PostgreSQL and upstream calls it unsuitable there.
- Do not raise the log level to `debug`: those logs carry query bodies and credentials.compose.local.ymlthe services, pinned · local layout65 lines
# FerretDB · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://docs.ferretdb.io/installation/ferretdb/docker/
# documentdb image ... https://docs.ferretdb.io/installation/documentdb/docker/
# authentication ..... https://docs.ferretdb.io/security/authentication/
#
# FerretDB turns the MongoDB wire protocol into SQL; PostgreSQL beside it holds
# the DocumentDB extension. Both stores are named volumes rather than folders,
# because the PostgreSQL image chowns its data directory to its own uid at first
# start and a home-directory bind mount cannot allow that on Windows. The two
# tags are a matched pair: the 2.7.0 release notes name 0.107.0-ferretdb-2.7.0
# as its match. Move them together.
#
# Digests read from ghcr.io on 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: ghcr.io/ferretdb/postgres-documentdb:17-0.107.0-ferretdb-2.7.0@sha256:2386795ec2aa7ae559304361979f1dc5708d383ee9020ae63dadc2940dfe58f7
container_name: ferretdb-postgres
restart: unless-stopped
environment:
# Upstream requires a `postgres` database to exist before FerretDB
# connects, so this name is not a preference.
POSTGRES_DB: postgres
POSTGRES_USER: ferretdb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
# Step 8 tars the cluster from a throwaway container borrowing these
# mounts, so the archive lands here on your own disk.
- ./backups:/backup
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ferretdb -d postgres"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the sibling container.
ferretdb:
image: ghcr.io/ferretdb/ferretdb:2.7.0@sha256:5706414241eb84f0515512c37b46db0f1b1eac9e5ceb7e4c2523211c184b1985
container_name: ferretdb
restart: unless-stopped
environment:
# FerretDB keeps no accounts: it forwards what a client sends to
# PostgreSQL, so this role is also the MongoDB login.
FERRETDB_POSTGRESQL_URL: "postgres://ferretdb:${POSTGRES_PASSWORD}@postgres:5432/postgres"
# Upstream's default `undecided` behaves as enabled after an hour. Off
# here; the cost is losing the new-version notice (step 9).
FERRETDB_TELEMETRY: disable
volumes:
# /state is a declared volume the image writes as uid 1000, holding the
# instance UUID and nothing of yours.
- state:/state
ports:
# Loopback only: no other device on the wifi can reach 8191, and nor
# can the phone you wanted to test an app from.
- "127.0.0.1:8191:27017"
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:
state:agent-readable mirror: /self-host/mongodb-atlas.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned55 lines
# FerretDB · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.ferretdb.io/installation/ferretdb/docker/
# documentdb image ... https://docs.ferretdb.io/installation/documentdb/docker/
# authentication ..... https://docs.ferretdb.io/security/authentication/
#
# FerretDB turns the MongoDB wire protocol into SQL; PostgreSQL beside it holds
# the DocumentDB extension. The two tags are a matched pair: the 2.7.0 release
# notes name 0.107.0-ferretdb-2.7.0 as its match. Move them together.
#
# Digests read from ghcr.io on 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: ghcr.io/ferretdb/postgres-documentdb:17-0.107.0-ferretdb-2.7.0@sha256:2386795ec2aa7ae559304361979f1dc5708d383ee9020ae63dadc2940dfe58f7
container_name: ferretdb-postgres
restart: unless-stopped
environment:
# Upstream requires a `postgres` database to exist before FerretDB
# connects, so this name is not a preference.
POSTGRES_DB: postgres
POSTGRES_USER: ferretdb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/ferretdb/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ferretdb -d postgres"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the sibling container.
ferretdb:
image: ghcr.io/ferretdb/ferretdb:2.7.0@sha256:5706414241eb84f0515512c37b46db0f1b1eac9e5ceb7e4c2523211c184b1985
container_name: ferretdb
restart: unless-stopped
environment:
# FerretDB keeps no accounts: it forwards what a client sends to
# PostgreSQL, so this role is also the MongoDB login.
FERRETDB_POSTGRESQL_URL: "postgres://ferretdb:${POSTGRES_PASSWORD}@postgres:5432/postgres"
# Upstream's default `undecided` behaves as enabled after an hour. Off
# here; the cost is losing the new-version notice (step 9).
FERRETDB_TELEMETRY: disable
volumes:
# /state is a declared volume the image writes as uid 1000, holding the
# instance UUID and nothing of yours.
- /srv/ferretdb/state:/state
ports:
# Loopback only, and no reverse proxy: the wire protocol is not HTTP,
# so 8191 answers on this box alone.
- "127.0.0.1:8191:27017"
depends_on:
postgres:
condition: service_healthyCaddyfilethe hostname and TLS13 lines
# FerretDB · this service gets no Caddy site block, and this file is the record # of that decision rather than something to append to /etc/caddy/Caddyfile. # # Authored by caniselfhostit from # https://docs.ferretdb.io/security/tls-connections/ and # https://caddyserver.com/docs/caddyfile/directives/reverse_proxy # # The MongoDB wire protocol on TCP 27017 is binary traffic on a long-lived # socket, not HTTP: `reverse_proxy` has nothing to carry and no hostname to # certify. Reaching 127.0.0.1:8191 from off this box means an ssh port forward, # or FERRETDB_LISTEN_TLS with certificates you issue. Never a proxy. # # NOT YET VERIFIED: no harness run has been recorded against this file.
install.shthe same install, no agent175 lines
#!/usr/bin/env bash
# FerretDB · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# ./install.sh
#
# There is no domain to pass and nothing is published to the internet. FerretDB
# answers the MongoDB wire protocol on 127.0.0.1:8191, so the clients are
# applications on this box, containers that join this compose project's network,
# and people who forward the port over their own ssh session.
#
# Authored by caniselfhostit from the upstream documentation:
# https://docs.ferretdb.io/installation/ferretdb/docker/
# https://docs.ferretdb.io/installation/documentdb/docker/
# https://docs.ferretdb.io/configuration/flags/
# https://docs.ferretdb.io/security/authentication/
#
# One secret is generated here, on this machine: the PostgreSQL password. It is
# also the password every MongoDB client sends, because FerretDB stores no
# accounts of its own. It goes into /srv/ferretdb/.env with mode 600 and is
# never printed.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/ferretdb}"
MSH="ghcr.io/ferretdb/ferretdb-eval:2.7.0@sha256:1bf47a449dd65839aabfc1a535d1370c98326f8a90de20437eda0aeb30bd8dd5"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; FerretDB plus PostgreSQL wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
# --- 2. Lay the files out ----------------------------------------------------
#
# postgres stays root-owned at 700: the PostgreSQL image chowns its own data
# directory on first start and refuses one that is already claimed. state is
# uid 1000 because that is the uid in the FerretDB image's passwd file.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/state"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64 because it travels inside a connection string. Read it
# later with
# sudo grep POSTGRES_PASSWORD /srv/ferretdb/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
POSTGRES_PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy: no site block, on purpose -------------------------------------
#
# The MongoDB wire protocol is not HTTP, so reverse_proxy has nothing to carry
# and there is no hostname to certify. The Caddyfile installed above is the
# record of that decision; /etc/caddy is left exactly as it was.
if [ -f /etc/caddy/Caddyfile ]; then
caddy_hits="$(sudo awk '/ferretdb/ {n++} END {print n+0}' /etc/caddy/Caddyfile)"
[ "$caddy_hits" = "0" ] || die "/etc/caddy/Caddyfile mentions ferretdb ${caddy_hits} time(s). An earlier attempt published this database. Remove that site block, reload caddy, and run this again."
fi
# --- 5. Ports: none open, and neither 8191 nor 5432 is one of them -----------
if command -v ufw >/dev/null 2>&1; then
echo "==> this install opens no port; 8191 is loopback and 5432 is never published"
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The image carries a HEALTHCHECK that upstream documents as behaving like a
# readiness probe: it passes only when a MongoDB protocol connection can be made
# and DocumentDB is installed correctly, so one word covers both containers.
docker compose pull
docker compose up -d
echo "==> waiting for the ferretdb container to report healthy"
health=""
for _ in $(seq 1 36); do
health="$(docker inspect --format '{{.State.Health.Status}}' ferretdb 2>/dev/null)" || health=""
[ "$health" = "healthy" ] && break
sleep 5
done
[ "$health" = "healthy" ] || die "the ferretdb container reported '${health:-nothing}'. Check: docker compose logs --tail 40 ferretdb"
# The wire protocol, end to end: a write and a read through mongosh. The shell
# comes from FerretDB's own evaluation image, pinned by digest and run with its
# entrypoint overridden so none of the services it carries ever start. That pull
# is roughly 600 MB and nothing keeps it afterwards.
echo "==> pulling the MongoDB shell image (about 600 MB, used once)"
docker pull "$MSH" >/dev/null
PGPW="$(sudo grep '^POSTGRES_PASSWORD=' "$APP_DIR/.env" | cut -d= -f2-)"
docker run --rm --network container:ferretdb --entrypoint mongosh "$MSH" --quiet \
"mongodb://ferretdb:${PGPW}@127.0.0.1:27017/appdb" \
--eval 'db.selfhost_check.insertOne({ok:1}); printjson(db.selfhost_check.findOne())'
unset PGPW
# An anonymous client can open a socket to FerretDB. What it must not be able to
# do is read or write anything, and that is what this asserts.
if anon="$(docker run --rm --network container:ferretdb --entrypoint mongosh "$MSH" --quiet \
"mongodb://127.0.0.1:27017/appdb" \
--eval 'db.selfhost_check.insertOne({anon:1})' 2>&1)"; then
printf '%s\n' "$anon"
die "an unauthenticated client inserted a document. Authentication is not being enforced: check FERRETDB_POSTGRESQL_URL in compose.yml and stop until it is fixed."
fi
printf '==> unauthenticated write refused, as it must be:\n%s\n' "$anon"
# --- 7. The first backup, before day one ends --------------------------------
#
# Cold, because a PostgreSQL data directory copied while the server is writing
# is not a backup and a file copy of a stopped cluster is correct at any size.
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/ferretdb-${STAMP}.tar.gz" -C "$APP_DIR" postgres state .env compose.yml Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/ferretdb-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
FerretDB is answering the MongoDB wire protocol on 127.0.0.1:8191
1. Your connection string is
mongodb://ferretdb:<the value in .env>@127.0.0.1:8191/appdb
It works from an application on this box as written, and from a
container in another compose project only if that container joins
this project's network, where the host becomes ferretdb:27017.
From your own laptop, forward the port first, on the laptop:
ssh -N -L 27017:127.0.0.1:8191 vps
2. The password is in $APP_DIR/.env, mode 600. Read it with
sudo grep POSTGRES_PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here. It is
the whole security boundary: upstream states authorization is not
yet supported, so every valid login has full access to everything.
3. A test document was written to appdb.selfhost_check and read back.
Drop it whenever you like through the same shell:
db.selfhost_check.drop()
4. There is no web interface, no sign-in screen and no public hostname.
Nothing was added to /etc/caddy/Caddyfile and no port was opened.
5. First backup written to $APP_DIR/backups. It is on the same disk as
the data, which is not a backup. Copy it somewhere else tonight.
Treat that archive as credential material, because .env and every
document you have written are inside it.
6. The two image tags are a matched pair. When you update, move the
PostgreSQL image first, run ALTER EXTENSION documentdb UPDATE, then
move FerretDB. Releases: github.com/FerretDB/FerretDB/releases
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel MongoDB Atlas.
- Compatibility is the whole question, and the answer is honest rather than complete. Upstream says drivers and applications built for MongoDB 5.0+ should work, and marks CRUD, indexes, aggregate, count and distinct supported. It also says transactions, bulkWrite, every role-management command, setParameter, killOp and profile are not implemented, and that error messages can differ where the error names match. Atlas Search, Atlas Vector Search, Charts and Triggers are services MongoDB runs beside the database and none of them exist here. mongodump and mongorestore work against FerretDB, so the data moves; anything built on transactions or on the Atlas services has to be rewritten first. Run your test suite against it before you migrate anything.
- There is authentication and there is no authorization. FerretDB keeps no accounts: it forwards the credentials a client sends to PostgreSQL, which validates them over SCRAM-SHA-256. Upstream states plainly that authorization is not yet supported, so every valid login has full access to every database, and there is no read-only account to hand a reporting script.
- You own a PostgreSQL, and you own a version pair. This runs two containers, and the FerretDB tag and the DocumentDB tag are matched: upstream names the DocumentDB release each FerretDB release works best with, and upgrading means the PostgreSQL image first, then ALTER EXTENSION documentdb UPDATE, then FerretDB. Move one without the other and you are on a combination nobody tested.
- Nothing here is a managed service. Atlas gives you a replica set on three nodes, continuous backups with point-in-time restore, and autoscaling; this gives you one container on one disk, a backup you run yourself, and downtime whenever the box reboots. The wire protocol is not HTTP, so there is no reverse proxy and no certificate: the database answers on loopback, and reaching it from elsewhere means an SSH tunnel or FerretDB's own TLS listener with certificates you issue.
- The project is quieter than it was, and you should know that before you commit data to it. Version 2.7.0 shipped on 2025-11-10 and is still the newest release; the default branch's last commit is a dependency bump dated 2026-02-07. The company's own site at www.ferretdb.com has been answering a Squarespace expiry page since at least May 2026, with the upstream bug report about it still open. What is demonstrably fine on the day this page was written: the repository is not archived, the documentation and the published images are live, and issues are still being filed. Read that as a real risk to weigh, not as a verdict, and note that the escape hatch is unusually good here because your data is sitting in an ordinary PostgreSQL.
Where this came from
“An anonymous client may still connect to FerretDB without authentication, but they cannot access or perform actions on the database.”
- Upstream states that all drivers and applications compatible with MongoDB 5.0+ should be compatible with FerretDB, and the same page lists transactions, bulkWrite and every role-management command as not implemented, with error messages that may differ where the error names match. source
- Authentication is enabled by default, FerretDB stores no usernames or passwords of its own and forwards credentials to PostgreSQL for validation, only SCRAM-SHA-256 is supported on the client, and authorization is not yet supported. source
- The 2.7.0 release notes name DocumentDB v0.107.0-ferretdb-2.7.0 as the version this release works best with, and state that FerretDB 2.6.0 was skipped to align the DocumentDB and FerretDB version numbers. source
- FERRETDB_POSTGRESQL_URL, FERRETDB_LISTEN_ADDR (:27017 in Docker), FERRETDB_DEBUG_ADDR (:8088 in Docker), FERRETDB_STATE_DIR (/state in Docker), FERRETDB_AUTH and FERRETDB_TELEMETRY are the documented environment variables this install uses or deliberately leaves alone. source
- All FerretDB Docker images carry a HEALTHCHECK that behaves like the readiness probe, which checks that a MongoDB protocol client connection can be established and therefore that PostgreSQL and DocumentDB are configured correctly. source
- Telemetry defaults to undecided, which behaves as enabled with the first report delayed by one hour, and setting FERRETDB_TELEMETRY to disable turns it off at the documented cost of losing automated version checks. source
- The LICENSE file at tag v2.7.0 is the Apache License 2.0 in full, and it is the only license file in the repository at that tag. source
- v2.7.0, released 2025-11-10, is still the newest release, and the default branch's most recent commit is a dependency bump dated 2026-02-07. source
- The company site at www.ferretdb.com answers 404 with a Squarespace expiry page, and upstream issue 5650, Website is down, has been open since 2026-05-19. The documentation, blog and cloud hosts are unaffected. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host MongoDB Atlas?
Not MongoDB Atlas itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is FerretDB. The MongoDB wire protocol your drivers already speak, answered by a PostgreSQL you own and can back up. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 75 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces MongoDB Atlas?
FerretDB. The MongoDB wire protocol your drivers already speak, answered by a PostgreSQL you own and can back up. The only option here that keeps your existing MongoDB drivers and your existing query code. FerretDB answers the MongoDB wire protocol and stores documents in PostgreSQL with the DocumentDB extension, so an application talks to it unchanged, and the data underneath is a PostgreSQL cluster you can back up with tools that have existed for decades. Apache-2.0 at the pinned tag, two containers, and one credential. The honest limits are published rather than discovered: no transactions, no bulkWrite, no role management, and none of the Atlas services that sit beside the database. One caveat to weigh on the way in: the project has been quiet, with 2.7.0 the newest release since November 2025 and the main branch last moved in February 2026, while the company's own website has been expired since May 2026. The repository is not archived and the images and documentation are live, and the data underneath is plain PostgreSQL, which is the best escape hatch any option on this page offers. FerretDB is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to MongoDB Atlas?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. FerretDB itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: MongoDB Atlas Dedicated (M10), $56.94/mo — $683.28 a year, a metered rate, not a whole bill.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the FerretDB install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run FerretDB on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same FerretDB on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: The database answers on 127.0.0.1:8191 and nowhere else, so the application you build on it has to run on this computer too, and the phone you wanted to test from cannot reach it at all. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.