
Docker on macOS: Why Bind Mounts Corrupt Your Databases
Never bind-mount a database data directory from macOS into a container. Docker Desktop runs your containers in a Linux VM, and every host path you bind-mount arrives through a file-sharing layer — gRPC-FUSE or VirtioFS — that doesn't reproduce the fsync, rename and locking semantics Postgres, Mongo and SQLite are built on. Put database data in a named volume. Save bind mounts for code and config. Here's the corruption that taught me, and how to migrate without losing anything.
The Morning a Container Wouldn't Start
I run my whole self-hosted stack on one Mac mini M4 with 16GB of RAM: roughly 34 containers covering n8n, Supabase, a chat platform, a scheduling app, analytics, a password vault, and a pile of side projects. The internal 256GB disk filled up fast, so I did the obvious thing and pointed the data directories at an external SSD mounted at /Volumes/ExternalSSD. Compose file, one line per service, ./data:/data/db. Done.
It worked for weeks. Then one morning a container came up and immediately fell over, and the logs pointed at a storage-engine file — a WiredTiger journal artifact — that the engine could no longer make sense of. Not “disk full.” Not “permission denied.” The file was there, the right size, and internally inconsistent. The database had written data it believed was durable, and the bytes that landed on disk weren't the bytes it wrote.
I got it back with mongod --repairagainst a copy of the directory, and lost a small amount of recent state. The real lesson wasn't the repair command. It was that I had built a plausible-looking setup that violated an assumption every database engine makes, and nothing warned me until the data was already wrong.
The rule I now apply without exception:
On macOS, a bind mount is a network filesystem wearing a local filesystem's clothes. Code, config and logs can live there. A write-ahead log cannot.
Why a Bind Mount Breaks a Database and a Named Volume Doesn't
There is no Linux kernel on your Mac. Docker Desktop boots a lightweight Linux VM and runs every container inside it. That means your container's idea of a filesystem and your Mac's idea of a filesystem are two different things, and something has to translate between them.
For a bind mount, that translator is a file-sharing protocol — historically osxfs, then gRPC-FUSE, now VirtioFS by default. Every write(), every fsync(), every rename(), every flock() your database issues gets marshalled across the VM boundary and replayed against APFS on the host. Docker's own issue tracker is full of what happens next: Postgres reporting unexpected data beyond EOF, MariaDB's InnoDB refusing to start, files reading back at a different size in the container than on the host.
For a named volume, there is no translator. The volume lives inside the VM's own virtual disk — a single Docker.raw image formatted ext4. The container writes to a real Linux filesystem, and fsync means what the Postgres docs say it means. That one indirection is the entire difference.
This matters because databases don't just need writes to land. They need them to land in a specific order, and they need fsync to be a genuine promise. Write-ahead logging is a bet that if the WAL record is durable, the page write can be replayed. When the sharing layer reorders, buffers, or lies about a flush, the recovery algorithm reads a WAL that describes a world the data files were never in.
- Bind mount → host path proxied over a file-sharing protocol across a VM boundary. Right for source code, config, static assets, log output.
- Named volume → real ext4 inside the Linux VM disk image. Right for Postgres, MySQL, MongoDB, Redis persistence, SQLite, Elasticsearch — anything with a storage engine.
- External drive → adds USB enclosure, APFS, disk sleep and unmount-on-wake on top of all of the above. The worst possible place for a bind-mounted data directory.
And no, switching to VirtioFS does not make this safe. VirtioFS is meaningfully faster and fixes real consistency bugs, but it is still a protocol crossing a boundary, and corruption reports for database workloads exist on VirtioFS too. Speed is not durability. The same rule applies to Colima, Rancher Desktop, OrbStack and Podman Machine on macOS — they all run a VM, they all share host paths over some protocol, and none of them turn a shared path into a filesystem a storage engine can trust.
What a Correct Compose File Looks Like
The fix is boring, which is the point. Data goes in a named volume, everything a human edits stays a bind mount:
services:
db:
image: postgres:18
volumes:
- pgdata:/var/lib/postgresql/data # named volume — safe
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro # bind — fine
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/pg_pw
app:
build: .
volumes:
- ./src:/app/src # bind — this is what it's for
- appfiles:/app/uploads # named volume for anything written
volumes:
pgdata:
appfiles:Note the second named volume. User uploads, generated files and cache directories are written by the app at runtime, so they belong in a volume too — not because they'll corrupt the way a B-tree will, but because a half-written file on a shared path is still a half-written file.
The one thing you give up is convenience: you can no longer ls the database directory from Finder. That is a feature. If you can browse your Postgres data directory in Finder, macOS can touch it — and Spotlight indexing a live data directory is its own category of bad day.
Migrating an Existing Bind Mount Without Losing Data
If you already have a database on a bind mount, don't just swap the compose line — copy the data across properly. Four steps, about twenty minutes for a small database.
1. Dump first, while it still runs. A logical dump is the only artifact guaranteed to be readable after a corruption event, because it is re-serialized by the engine rather than copied byte-for-byte:
docker compose exec db \ pg_dump -U postgres --format=custom mydb > ~/backups/mydb.dump
2. Stop the container and create the volume. Stop it cleanly so the engine checkpoints and closes its files — docker compose stop db, not kill. Then docker volume create pgdata.
3. Copy the directory through a throwaway container so ownership and permissions survive the trip. Tar-over-pipe rather than cp, because cp will flatten the uid/gid the database needs:
docker run --rm \ -v /Volumes/ExternalSSD/pgdata:/from:ro \ -v pgdata:/to \ alpine sh -c "cd /from && tar cf - . | (cd /to && tar xpf -)"
4. Repoint compose, start, and verify before you delete anything.Bring the stack up, check the engine logs for a clean start with no recovery warnings, and compare row counts against the dump. Only then remove the old directory. I keep the old bind-mount folder around for a week — deleting it is the one step you can't undo.
But I Actually Do Need It on the External Drive
So did I — 34 containers do not fit on a 256GB boot disk. The answer isn't to bind-mount data directories to the external SSD. It's to move Docker's entire disk image there and keep using named volumes. The VM disk is one file; the guest filesystem inside it stays ext4; nothing crosses the sharing layer.
Quit Docker Desktop, then change the disk-image location under Settings → Resources → Advanced (or set the data-folder key directly in Docker's settings store, which is what I did so I could script it). Two things bit me here that nobody mentions:
- Copy the image with a sparse-aware copy. A plain cp reads the holes in a sparse disk image as real zero bytes and turns a 40GB-used image into its full provisioned size. Use a sparse-preserving copy tool and check the on-disk size afterward, not the apparent size.
- Stop Docker from auto-starting at login. If the Mac reboots and Docker launches before the external volume mounts, Docker sees an empty path and cheerfully creates a brand-new blank disk image. I turned auto-start off and used a launch agent that waits for the volume to appear before starting Docker.
- Verify after the first reboot, not after the migration. The migration always looks fine. The reboot is where the race condition lives.
This setup has been stable for months across reboots, drive sleeps and Docker updates — which the bind-mount version never was.
Named Volumes Move Your Backup Problem, They Don't Solve It
Here's the trade you just made: your data is now safe from the sharing layer, and completely invisible to Time Machine. It's inside one enormous disk image that Time Machine will either skip or copy whole, and a snapshot taken mid-write isn't a consistent backup anyway.
I run a nightly job that dumps every database with its own engine's tool and ships the dumps to a NAS over SSH. Eight databases, one script, one cron entry. It runs at 3am and it has restored real data more than once. The rules that matter:
- Use the engine's dump tool — pg_dump, mongodump, redis BGSAVE. Never tar a live data directory and call it a backup.
- Ship the dump off the machine. A backup on the same disk as the database is a copy, not a backup.
- Back up the secrets separately. n8n encrypts credentials with a key that is not in the database — restore the database without that key and every credential is dead weight.
- Restore-test on a schedule. An untested backup is a hypothesis.
If you're running n8n or Supabase on this kind of setup, I've written about the storage decisions underneath them in Postgres vs Redis for n8n agent memory and building a lead database on Supabase and n8n. When you start pushing real concurrency through the same box, n8n queue mode is the next thing to get right, and observability is how you notice a problem before a container refuses to boot. For the underlying guarantees, Docker's volumes documentation and the PostgreSQL manual on WAL reliability are both worth twenty minutes.
The Short Version
- Docker on macOS runs a Linux VM. Bind mounts cross that boundary over a file-sharing protocol; named volumes don't.
- Databases assume fsync, ordering and locking are real. The sharing layer doesn't guarantee any of the three.
- Named volumes for anything with a storage engine. Bind mounts for code, config and read-only fixtures.
- VirtioFS is faster than gRPC-FUSE, not safer for databases. Same rule.
- Need the space on an external drive? Move Docker's whole disk image, not individual data directories — and make Docker wait for the volume to mount at boot.
- Named volumes are invisible to Time Machine. Schedule engine-level dumps and ship them off the machine.
Running a Self-Hosted Stack You Can't Afford to Lose?
I build and run production automation stacks — n8n, Supabase, Postgres, AI agents — on hardware that has to stay up, with backups that have actually been restored. If your setup works right up until it doesn't, let's talk.
Related Posts
Full-Stack
From Idea to Deployed AI Agent: My Full Stack in Allen, TX
The exact stack I use to build and deploy AI agents — n8n, Supabase, Claude, Vercel — with the wiring that holds it together.
n8n
Scaling Self-Hosted n8n: When to Switch to Queue Mode (2026)
Default n8n runs the editor, webhooks, and every execution in one Node process — it works until the UI crawls during runs and webhooks drop under load. The signal to move is the main process pinned near 80% CPU; the fix is queue mode: a main instance, a Redis broker, and dedicated workers on Postgres. The exact signals I watch, the env vars I set, and the mistakes that cost me a night of dropped executions.
AI Agents
MCP Tasks: How Long-Running MCP Tools Stop Timing Out
A tool that takes two minutes cannot be a blocking JSON-RPC call — something between your client and your server will kill it. The MCP Tasks extension hands back a task handle instead: taskId, ttlMs, pollIntervalMs, five states, three methods. Here is the full lifecycle, the migration from the 2025-11-25 experimental version, and the four bugs I hit rolling my own poll loop.