SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Databases in Docker, or on the host?

Running Postgres, MySQL, MongoDB or Redis in a container is fine in production. The risks are volumes, upgrades, backups and memory limits.

Should the database run in Docker or on the host?

Run the database in Docker. For one application stack on one VPS, a containerised PostgreSQL, MySQL, MongoDB or Redis is a normal production choice, and the argument people have about it is usually the wrong argument. A container is a Linux process with namespaces and cgroups around it, not a virtual machine, so there is no hypervisor between the database and the disk. With a bind mount or a local named volume, reads and writes land on the host filesystem, the same one a package install would use.

The real cost is operational. Four things decide whether this setup is fine or a slow disaster: where the data lives, who owns that directory, what a major version upgrade looks like, and whether you have ever restored a backup. Get those right and the container is a detail. Get them wrong and the container is the thing you will blame.

This is the same decision for every server database. The examples below use PostgreSQL, MySQL, MongoDB and Redis, and the product-specific differences are called out where they matter.

What a container actually changes

Not the storage path, as long as you mount one. Same kernel, same page cache, same filesystem.

There is one real performance trap, and it is the case where you mount nothing. Without a volume, the data directory goes into the container's writable layer, which is an overlay filesystem stacked on the image. Writes there are slower, and the whole layer is deleted when the container is removed. That is where "my database was empty this morning" comes from.

What genuinely changes:

  • The lifecycle. docker compose down destroys the container. Anything that was not in a volume goes with it.
  • The version. The image tag is the version. There is no apt upgrade inside a database container that survives the next docker compose pull.
  • Memory accounting. A cgroup limit is a hard wall enforced by the kernel, and the database does not know it is there.
  • The user. The process runs as a numeric user id inside the container, which may own nothing on your host.

Where the data lives decides everything

There are two good options and one common mistake.

  • A named volume: pgdata:/var/lib/postgresql/data. Docker creates the directory at /var/lib/docker/volumes/<project>_pgdata/_data, and the image entrypoint sets ownership on first run. This is the default answer.
  • A bind mount: /srv/appname/pg:/var/lib/postgresql/data. You choose the path, so you own the permissions problem.
  • No mount at all. See above. The data is in the container.

The full trade-off is its own topic, and bind mounts against named volumes covers it. For a database the short version is: use a named volume unless you have a specific reason to know the host path, and if you do use a bind mount, put it somewhere stable like /srv/appname/pg rather than inside the project directory where a git clean can reach it.

One hard limit: do not put a database data directory on NFS (network file system) or any network mount whose locking and fsync behaviour you have not tested. Databases assume that a successful fsync means the bytes are on stable storage. When that assumption is wrong, you get corruption that shows up weeks later.

Pin the volume name before the volume goes missing

Compose names a volume <project>_<volume>, and the project name defaults to the directory name. So the volume identity depends on a directory name, which is a thing people change without thinking.

Move /srv/app to /srv/app-old, or rename the pgdata key in the compose file, and the next docker compose up -d creates a brand new empty volume. Postgres initialises a fresh cluster into it. The container is healthy, the application starts, and every table is gone. The old volume is still on disk under the old name, which is the good news.

docker volume ls
docker volume inspect app_pgdata

Pin the names so this cannot happen. Set the project name and the volume name explicitly:

name: myapp

services:
  db:
    image: postgres:17
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
    name: myapp_pgdata

If a stray volume already holds your data, copy it across with the database stopped:

docker compose stop db
docker run --rm -v app_pgdata:/from -v myapp_pgdata:/to alpine sh -c 'cp -a /from/. /to/'
docker compose start db

Copy it while the database is running and you get a torn copy of files that were being written. Stop it first.

Who owns the data directory

The official Postgres, MySQL and MongoDB images run their server as an unprivileged user id, usually 999. When the container starts as root, the entrypoint changes ownership of the data directory to that user and then drops privileges. That is why an empty bind mount usually works on the first try.

It breaks the moment you set user: in the compose file, because then the entrypoint has no privilege left to fix anything. Postgres says so directly:

initdb: error: could not change permissions of directory "/var/lib/postgresql/data": Operation not permitted

A data directory that exists with the wrong mode gives a different message, and this one is worth recognising because the fix is chmod, not chown:

FATAL:  data directory "/var/lib/postgresql/data" has invalid permissions
DETAIL:  Permissions should be u=rwx (0700) or u=rwx,g=rx (0750).

MongoDB on a root-owned bind mount fails at the lock file:

Unable to create/open the lock file: /data/db/mongod.lock (Permission denied). Ensure the user executing mongod is the owner of the lock file and has the appropriate permissions.

The fix is to chown the host directory to the numeric id, not to a name:

sudo chown -R 999:999 /srv/appname/pg
sudo chmod 700 /srv/appname/pg
ls -ldn /srv/appname/pg

ls -ldn prints numbers instead of names, and it should show 999 999. The account called postgres on your host and the account called postgres inside the image are unrelated: the kernel compares numbers, and the names are looked up separately on each side. how PUID and PGID map host users into a container goes through that mapping properly. Under rootless Docker or user namespace remapping the numbers shift again, so read the ids from the running container rather than assuming 999.

Named volumes make this whole section disappear on first run, because Docker creates an empty directory and the entrypoint owns it.

Upgrades: a package upgrade against an image tag change

On the host, apt upgrade moves you along a minor version. Your distribution will not jump a database major version under you, and when you choose to jump, both sets of binaries can be installed at once, which is exactly what pg_upgrade needs.

In a container the tag is the version, so an upgrade is editing one line. That makes minor upgrades trivial and major upgrades a procedure.

Change postgres:16 to postgres:17, run docker compose up -d, and the container exits at once:

PostgreSQL Database directory appears to contain a database; Skipping initialization
FATAL:  database files are incompatible with server
DETAIL:  The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.

Nothing is damaged. The new binaries refuse to read the old on-disk catalog layout, which changes between major versions. Put the tag back to postgres:16 and it starts again. That rollback is the one genuine upgrade advantage containers give you.

The supported path is dump and restore. PostgreSQL prefers the dump to be taken by the newer client, so run it from the new image against the still-running old server on the compose network:

docker run --rm --network myapp_default -e PGPASSWORD="$POSTGRES_PASSWORD" \
  postgres:17 pg_dumpall -h db -U postgres > /srv/backups/all.sql
ls -lh /srv/backups/all.sql
tail -n 2 /srv/backups/all.sql

The file should be tens of kilobytes at minimum and end with a line reading PostgreSQL database cluster dump complete. A file of a few hundred bytes means the dump failed and you are about to delete a volume for nothing. Only after that check:

docker compose down
docker volume rm myapp_pgdata
# edit the compose file: image: postgres:17
docker compose up -d db
docker compose exec -T db psql -U postgres -f /dev/stdin < /srv/backups/all.sql

The other engines differ:

  • MySQL 8 upgrades its own data dictionary at startup, so a minor tag bump is usually just a restart. Read the release notes before a jump between release series, and take a dump first either way.
  • MariaDB expects mariadb-upgrade to be run after the server comes up on the new version.
  • MongoDB must be upgraded one major version at a time, and after each step you set the feature compatibility version before moving on. Skipping a version means mongod refuses to start and logs an UPGRADE PROBLEM line naming featureCompatibilityVersion. From MongoDB 7.0 onward the command needs an explicit confirmation flag: db.adminCommand({ setFeatureCompatibilityVersion: "8.0", confirm: true }).
  • Redis loads older snapshot files happily but not newer ones, so an upgrade is a restart and a downgrade can fail to load the data.

The general rule: a container makes downgrade easy and makes upgrade no easier.

Why does my database container exit with code 137?

Because the kernel's out of memory (OOM) killer killed it. 137 is 128 plus signal 9.

docker compose ps
docker inspect myapp-db-1 | grep -i oomkilled
journalctl -k | tail -n 20

docker compose ps shows Exited (137), the inspect line reads "OOMKilled": true, and the kernel log carries a matching entry:

Memory cgroup out of memory: Killed process 4711 (postgres) total-vm:2170416kB

Here is the mechanism, and it surprises people. PostgreSQL and MySQL size their buffers from what the host reports as total memory. A cgroup limit does not change that number for them. On a 16 GB host with a 2 GB limit, the database plans as though it has 16 GB, and the cgroup kills it long before the host itself is under any pressure. So a memory limit alone is not enough. You must also tell the database what it has:

  • PostgreSQL: set shared_buffers, and pay attention to work_mem. work_mem is allocated per sort operation per connection, so a generous value times fifty connections is the usual cause of a container that dies under load rather than at startup.
  • MySQL and MariaDB: set innodb_buffer_pool_size, which defaults to 128M. Leave innodb_dedicated_server off in a container, because its whole job is to size itself from detected machine memory.
  • MongoDB: set the WiredTiger cache size explicitly instead of letting it guess from host memory.
  • Redis: maxmemory defaults to unlimited, so Redis grows until the cgroup stops it. Set maxmemory comfortably below the container limit and choose a maxmemory-policy.

Postgres also reports the event from its own side, and this pair of lines is what you will find in the log:

LOG:  server process (PID 123) was terminated by signal 9: Killed
LOG:  terminating any other active sessions due to crash of another server process

One backend being killed forces every other backend to restart, because shared memory may now be inconsistent. That is a connection storm for your application, not a quiet event. setting memory limits in Docker Compose covers the syntax and the difference between mem_limit and the deploy.resources form.

None of this vanishes on the host. It moves. Without a cgroup the database competes with everything else on the box, and the host OOM killer picks a victim by score, which can be sshd. A limit that kills the database predictably is easier to operate than a host OOM that locks you out.

Backups: dump inside, back up outside

Do not back up a running database by copying its data directory. A file-level copy taken while the server is writing is a torn copy, and you find out at restore time.

Two honest methods: dump with the database's own tool while it runs and back up the dump, or stop the container and copy the volume cold.

docker compose exec -T db pg_dump -U postgres -Fc appdb > /srv/backups/appdb.dump
docker compose exec -T db mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --all-databases > /srv/backups/mysql.sql
docker compose exec -T db mongodump --archive --gzip --db appdb > /srv/backups/appdb.archive.gz
docker compose exec -T redis redis-cli BGSAVE

The -T matters. Without it, docker compose exec can attach a terminal to the command, and the terminal layer adds carriage returns to the output stream. A text dump then restores with strange errors and a binary dump is simply corrupt. It fails silently at backup time and loudly a month later.

--single-transaction gives mysqldump a consistent snapshot of InnoDB tables without locking the whole server.

Those commands write one file each. They are not a backup system: there is no retention, no off-box copy, and no verification. Hand the dump directory to a tool that does all three, which is what restic backups from a VPS is for. Back up /srv/backups, not /var/lib/docker/volumes.

Then run the restore, because a backup you have never restored is not a backup:

docker compose exec -T db createdb -U postgres restore_test
docker compose exec -T db pg_restore -U postgres -d restore_test < /srv/backups/appdb.dump
docker compose exec -T db psql -U postgres -d restore_test -c '\dt'

\dt should list your application's tables. An empty result, or Did not find any relations., means the dump is not what you think it is. Drop restore_test when you are done.

The command that deletes everything

docker compose down -v.

Plain down removes the containers and the network. The -v also removes every named volume declared in that compose file, plus every anonymous volume attached to those containers. There is no prompt and no undo. It is the most common way a self-hosted database is destroyed, and it usually happens during troubleshooting of something unrelated, because a forum answer said to run it.

Four things reduce the blast radius:

  • Declare the database volume external: true. Compose will not remove a volume it does not own, so -v cannot reach it. You create it once with docker volume create myapp_pgdata.
  • Use docker compose stop and docker compose start for routine restarts. down against stop in Compose spells out what each one removes.
  • Keep dumps on a host path outside every volume compose manages.
  • Never paste -v from a troubleshooting answer into a stack that holds data you care about.

Do not publish the database port

This line puts your database on the public internet:

    ports:
      - "5432:5432"

It binds to every interface. Docker publishes a port by rewriting the packet's destination before your firewall's input rules ever see it, and ufw's rules sit in the input chain, so ufw deny 5432 does nothing at all. why Docker published ports bypass ufw shows the chain traversal.

An application in the same compose project reaches the database by service name on the compose network, so it needs no published port. Delete the block. If you want a client on the host, bind to loopback only:

    ports:
      - "127.0.0.1:5432:5432"

Check what is actually listening:

sudo ss -ltnp | grep 5432

127.0.0.1:5432 is what you want. 0.0.0.0:5432 means anyone can try your password.

What to run where

One application on one VPS. Container. A named volume with a pinned name, no published port, a memory limit with matching database settings, and a nightly dump to a host path that restic collects. Start from a clean Docker install on a VPS and keep the stack in one compose file you commit. The win is real: the database version becomes a reviewable line in git.

A host running several services. Containers, one database per application, not one shared server for all of them. A shared server couples every application to one upgrade schedule, and one runaway query becomes everyone's outage. Give each container its own memory limit so a bad query is contained to the app that wrote it. Several small Postgres instances cost a little more disk and far less coordination.

The database is the product. Run it on the host from the vendor's package repository, or pay for a managed service. pg_upgrade needs both major versions of the binaries installed at the same time, which packages give you and a single-version image does not. Replication and point in time recovery with WAL (write ahead log) archiving are both easier when the database owns the machine and its disks. Choose the boring path for the system that will page you at 03:00.

The application is small. Consider running no server database at all. A single-writer web application on one VPS is often better served by SQLite in production on a VPS, where the backup is one file and the upgrade path is a library version.

FAQ

Is it safe to run a production database in Docker?

Yes, for a single-server application stack. A container is a Linux process with namespaces and cgroups around it, so with a volume mounted the database writes into the same host filesystem it would use if you installed it from a package. The risks are operational rather than about speed: a volume whose name is not pinned, a bind mount owned by the wrong user id, a restore you have never tested, and docker compose down -v. Close those four and the container is fine. Move to a host install when the database is the main workload and you need pg_upgrade, replication or point in time recovery.

Should I use a bind mount or a named volume for database data?

Use a named volume unless you have a specific reason to know the host path. Docker creates the directory and the image entrypoint sets ownership on first start, so the permission problem never appears. Pin the volume with an explicit name: or mark it external: true, otherwise renaming the project directory silently produces a fresh empty volume and an empty database. A bind mount is fine if you chown the host directory to the numeric user id the image runs as, which is 999 for the official Postgres, MySQL and MongoDB images. Verify it with ls -ldn, since ls -l shows your host's name for that number and that name is meaningless inside the container.

What does docker compose down -v delete?

It removes the containers and the network like a plain down, and the -v additionally removes every named volume declared in that compose file along with every anonymous volume attached to those containers. That includes the database. There is no confirmation prompt and no recovery. Volumes marked external: true are not removed, which is the main reason to mark a database volume external. For a routine restart use docker compose stop and docker compose start instead.

How do I upgrade PostgreSQL to a new major version in Docker?

Dump and restore. Changing postgres:16 to postgres:17 and restarting gives FATAL: database files are incompatible with server with a DETAIL line naming both versions, because the new binaries will not read the old catalog layout. Nothing is damaged: put the old tag back and it starts. Take a pg_dumpall using the new version's client against the running old container, confirm the file ends with PostgreSQL database cluster dump complete, then bring up the new tag on an empty volume and load the dump. Minor upgrades inside one major version need only a pull and a restart.

Why does my database container exit with code 137?

137 is 128 plus signal 9, so something killed the process outright. Run docker inspect <container> | grep -i oomkilled; a value of true means the container hit its cgroup memory limit. The usual cause is that PostgreSQL and MySQL read total memory from the host and never see the container's limit, so they plan for 16 GB while living inside 2 GB. Set shared_buffers and work_mem, or innodb_buffer_pool_size, to fit the limit you gave the container. Check journalctl -k for the matching Memory cgroup out of memory line to confirm which process the kernel picked.