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

Back up and upgrade a Docker Compose stack

Docker Compose deploy guides stop at the login screen. Here is the rest: what to dump, which volumes to capture, how to prove a restore, and how to upgrade.

What a Docker Compose stack backup has to contain

A Docker Compose stack backup has to hold four separate things, and losing any one of them means the app does not come back: the compose file, the .env beside it, the contents of every volume, and a database dump written by the database's own client. Copying a database's files while its container runs is not a backup. Upgrades use that same list plus one rule: take the backup before you pull, because schema migrations are written to run forwards and most projects ship no way back.

Everything below assumes the stack is already deployed and that docker compose ps shows it running. The examples use a project directory at /srv/myapp with services named app and db. Substitute your own names. The commands stay generic on purpose, because the parts that matter, the volumes and the database, work the same way whatever the app is.

Work out what your stack actually stores

cd /srv/myapp
docker compose ps
docker compose config --volumes
docker volume ls --filter label=com.docker.compose.project=myapp

docker compose config --volumes prints the short names of the named volumes your file declares. docker volume ls prints the names those volumes really carry on disk. The two lists differ, because Compose puts the project name in front: a volume written as db_data in the file exists as myapp_db_data. The project name defaults to the directory name, so renaming the directory points the stack at a fresh set of empty volumes and leaves the old ones sitting there full of your data. Every command below needs the real name from docker volume ls.

Bind mounts appear in neither list. In the compose file they are the entries with a host path on the left of the colon, ./config:/app/config. They are ordinary directories on the host, so ordinary tools reach them. Named volumes live under /var/lib/docker/volumes/, and docker volume inspect --format '{{.Mountpoint}}' myapp_db_data prints the exact path of one. Which kind your stack uses changes how you copy it, and bind mounts against named volumes covers the trade-off in full.

Now sort what you found into two groups. Some volumes hold state nothing can recreate: uploaded files, generated keys, the database itself, and anything a user typed into the app. Others hold derived data such as thumbnails and search indexes, which the app rebuilds on its own. Backing up the second group costs disk space and restore time and buys nothing. A Redis cache volume is the clearest example: losing it costs one slow first request.

Back up the compose file and the .env file

Both files sit on the host next to each other, and neither is inside any volume. The .env holds the database password, the application secret and any API tokens, so it is the file that turns a pile of volumes back into a working app. It is also normally listed in .gitignore, which means a plan of "my configuration is in git" excludes the single file that matters most. Keeping secrets in an env file is the right pattern, and it puts a matching duty on your backup.

sudo install -d -m 700 -o "$USER" -g "$(id -gn)" /srv/backups/myapp
cp -a compose.yaml .env /srv/backups/myapp/
chmod 600 /srv/backups/myapp/.env

Copy every compose file the stack uses, not only the first one. A stack started with -f compose.yaml -f compose.prod.yaml needs both files to come back the same way, and how multiple compose files merge decides which values actually reach the container.

One warning ties the .env to the volumes. The official Postgres image reads POSTGRES_PASSWORD only when it initialises an empty data directory. Changing that value later does not change the password inside the database. Restore last month's volume next to today's .env and the app fails to connect with FATAL: password authentication failed for user "appuser", while both files look correct on inspection. Keep the .env and the volumes from the same moment together in the same backup.

Dump the database with its own client

A database server writes to its files constantly. A tar of /var/lib/postgresql/data taken while the server is running copies some pages from before a write and some from after, so the archive holds a mix of moments that may not replay. A dump tool reads inside a single transaction, so the file holds one consistent moment. That difference is what separates a backup from a copy.

docker compose exec -T db sh -c \
  'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
  > /srv/backups/myapp/db-$(date +%F).dump

Keep the -T. It turns off TTY allocation, and with a TTY attached Docker translates the output stream on its way to your shell, which corrupts a binary dump. You will not learn about that until the restore fails. The single quotes matter as well: they stop your host shell expanding $POSTGRES_USER, so the shell inside the container expands it instead, using the values the compose file already sets there. -Fc writes the custom format, which compresses as it goes and lets pg_restore pick objects out of it later.

Roles and their passwords live outside any single database, so take those too:

docker compose exec -T db sh -c 'pg_dumpall -U "$POSTGRES_USER" --globals-only' \
  > /srv/backups/myapp/globals.sql

Then check that the file is a dump and not an error message:

ls -lh /srv/backups/myapp/
head -c 5 /srv/backups/myapp/db-$(date +%F).dump

A custom-format dump starts with the five bytes PGDMP. A file of zero bytes, or one starting with pg_dump:, means the command failed. The shell creates the output file before the command runs, so a failed dump still leaves a file behind with a plausible name and a plausible timestamp. That is the most common silent backup failure there is.

For MariaDB or MySQL the client changes and the shape does not:

docker compose exec -T db sh -c \
  'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction --databases "$MARIADB_DATABASE"' \
  > /srv/backups/myapp/db-$(date +%F).sql

--single-transaction gives a consistent dump of InnoDB tables without blocking writers. On the MySQL image the command is mysqldump and the variables are MYSQL_ROOT_PASSWORD and MYSQL_DATABASE. On current MariaDB images mysqldump still works as a compatibility name for mariadb-dump. Note that a password given on the command line is visible in the container's process list for as long as the dump runs.

SQLite needs its own care. The database is one file, but recent transactions may still sit in a separate -wal file beside it, so copying only the .db gives you a database missing its newest writes. If the image ships the client, sqlite3 /data/app.db ".backup '/data/app-backup.db'" writes a consistent copy while the app runs. If it does not, stop the container and copy the .db file together with its -wal and -shm companions.

If your database runs on the host instead of inside the stack, the same commands apply without the docker compose exec prefix, and running the database in Docker or on the host is worth reading before your next rebuild.

Capture the volumes

A named volume has no host path you should be editing by hand, so mount it into a throwaway container and archive it from there.

docker run --rm \
  -v myapp_uploads:/data:ro \
  -v /srv/backups/myapp:/backup \
  alpine:3 tar czf /backup/uploads.tar.gz -C /data .

The helper container mounts the volume read-only at /data and your backup directory at /backup, then writes the archive out to the host side. --rm removes the helper as soon as tar exits. The :ro matters, because a mistyped tar command then cannot damage the source. -C /data . is what makes the restore land in the right place: it stores every path relative to the volume root. Write tar czf /backup/uploads.tar.gz /data instead and every path gains a leading data/, so the restore creates /data/data inside the volume and the app sees an empty directory. The archive belongs to root, because tar ran as root inside the container. Run sudo chown "$USER" /srv/backups/myapp/uploads.tar.gz if that gets in your way, and read how PUID and PGID decide file ownership if the restored files come back unreadable to the app.

Run it once per named volume. Bind mounts need no container at all: tar czf /srv/backups/myapp/config.tar.gz -C /srv/myapp/config . does the same job on the host.

Decide per volume whether the app has to stop. A live tar of a volume the app rewrites in place can catch a file halfway through a write. For an uploads directory, where files are written once and then only read, that risk is small. For anything else, stop that service for the length of the copy with docker compose stop app, then docker compose start app. stop leaves the containers and the volumes in place, which is exactly what you want here, and the difference between down and stop is worth being sure about before you type either one.

Do not treat a tar of the database volume as your database backup. The dump is the backup. A volume archive of a stopped database is a useful fast rebuild path and nothing more.

The order of operations

  1. Copy the compose files and the .env into the backup directory.
  2. Dump the database while it is still running.
  3. Stop the app container if its volumes change in place.
  4. Archive each named volume and each bind-mount directory.
  5. Start whatever you stopped, then confirm with docker compose ps.
  6. Write down the image tags and digests the stack is running.
  7. Copy the whole backup directory off this server.

Step 7 is the one people leave for later.

Get the copy off the box

A backup on the same disk as the stack protects you from your own mistakes and from nothing else. One failed volume, one deleted server or one lost account takes both copies at the same time. Push the directory to storage that is not this VPS, on a schedule, with a retention policy. restic backups from a VPS covers the repository setup, the retention flags and the check command, so none of that needs repeating here.

restic can also read the dump straight from a pipe, which keeps the plaintext database off the disk entirely:

docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
  | restic backup --stdin --stdin-filename db.dump

Whatever tool you use, put the schedule in a systemd timer or a cron job, and make the job report failure somewhere you will see it. A backup script whose output goes nowhere is a backup script that can stop working for six months without anyone finding out.

Prove the backup works with a restore drill

A backup nobody has restored is a hypothesis. The drill below restores into a second stack running beside the first, so production keeps serving and nothing you type can reach it.

The mechanism is the project name. Compose takes it from the directory name and stamps it onto every container and volume it creates. Copy the backup into a new directory and the restored stack gets its own volumes automatically.

sudo install -d -m 700 -o "$USER" -g "$(id -gn)" /srv/myapp-restore
cd /srv/myapp-restore
cp /srv/backups/myapp/compose.yaml /srv/backups/myapp/.env .

Edit the copied compose file so the published host port cannot collide with the running stack, 18080:8080 in place of 8080:8080, or change the variable that sets it in the copied .env. Then create the containers and their empty volumes without starting anything:

docker compose create
docker volume ls --filter label=com.docker.compose.project=myapp-restore

The second command should list the same volume names as production with myapp-restore_ in front. Fill them, start the database on its own, and load the dump:

docker run --rm -v myapp-restore_uploads:/data -v /srv/backups/myapp:/backup \
  alpine:3 tar xzf /backup/uploads.tar.gz -C /data
docker compose up -d db
docker compose exec -T db sh -c \
  'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists' \
  < /srv/backups/myapp/db-2026-08-16.dump

--clean --if-exists drops each object before recreating it, which makes the restore repeatable. Without it, a second run into a database that already holds those tables stops with pg_restore: error: could not execute query: ERROR: relation "users" already exists.

Then start the rest and check it the way a user would:

docker compose up -d --wait
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\dt"'
docker compose logs --tail=50

docker compose up -d --wait blocks until every service reports running or healthy, and exits non-zero if one never does, which is what makes this step scriptable. When a service never turns healthy, docker compose ps shows its state, and Compose healthchecks explains what that column is reading. Then open the app on the alternate port and log in with a real account. Write one record and open one file that lives in a volume. That pair is the proof: the dump restored, the volume restored, and the two agree with each other. A drill that only proves the login page renders has proved nothing about your data.

Tear the drill down once it passes:

docker compose down -v

This is the one place where -v is the correct flag. In the production directory the same command deletes the volumes you are trying to protect.

How to upgrade a Compose stack

Read the release notes for every version between the one you run and the one you want, and search them for the words breaking and migration. Projects that do not support jumping across several major versions say so there, and a migration that refuses to run tells you only after it has already changed part of the schema.

Record what you are running now, before you change anything:

docker compose images
docker image inspect --format '{{index .RepoDigests 0}}' postgres:16.4

docker compose images lists the image and tag each service is using right now. The digest is the only value that names an image exactly, because a tag can be moved to point somewhere else at any time.

Take the backup from the sections above and copy it off the box. Do this for a patch release too. The cheap upgrades are the ones people stop preparing for.

Then pin the version in the compose file, because latest is not a version:

services:
  db:
    image: postgres:16.4

With image: postgres:latest, docker compose pull fetches whatever that tag points at today and you have no way to name what you were running yesterday. A pinned tag turns an upgrade into a one-line edit you can read in git diff and reverse with one more edit. Pin the application image the same way, taking the exact version from the project's release page.

Pull and recreate:

docker compose pull
docker compose up -d --wait

docker compose up -d compares the file against the running containers and recreates only the services whose image or configuration changed. It does not touch named volumes, so the new container starts on the existing data. That is the point of the exercise and also the risk, because the new version's first start is usually when its schema migration runs.

Watch it happen:

docker compose ps
docker compose logs -f --tail=100 app

A container that failed shows Exited (1) in the STATUS column of docker compose ps, and the reason is in the last lines of its log. Migration errors are loud there and invisible everywhere else. When the logs settle, log in and use the app for a minute.

If docker compose pull stops with no space left on device, old image layers are the usual reason, and pruning unused Docker images gets the space back. Do the pruning after the upgrade has proved itself, not before, because those old layers are what a fast rollback runs on.

How to roll back when the upgrade goes wrong

There are two cases and they cost very different amounts. If the new version did not change the schema, rollback is one line: put the old tag back in the compose file and run docker compose up -d. The container is replaced, the volumes stay where they are, and the old code reads the data it wrote.

If the new version migrated the schema, the old code can no longer read it. Migrations are written to run forwards, and most projects ship no downgrade script at all, so the old version starts and then fails on the first query against a column that has been renamed or dropped, with errors of the form ERROR: column "avatar_url" does not exist. The way back is the dump you took before pulling: put the old tag back, take the database volume away, recreate it empty, restore the dump into it, and start. Without that dump there is no way back at all, which is the whole reason the backup comes before the pull.

Postgres major versions are the sharpest form of this, and they surprise people because the failure lands on the upgrade rather than on the rollback. The on-disk format changes with every major release. Change postgres:16.4 to postgres:17.2, run docker compose up -d, and the new server refuses to start:

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

The image does not run pg_upgrade for you. The supported path inside a Compose stack is dump, replace, restore: dump with the old version still running, docker compose down, remove the database volume, set the new tag, docker compose create for a fresh empty data directory, start the database, restore the dump, start the rest. Keep the old dump until the new major has handled real traffic for a day. Minor upgrades inside one major, 16.4 to 16.9, need none of this, because the format is stable across them and the container simply starts.

Are VPS snapshots a backup?

They are a complement to one, and the two fail in different ways. A snapshot copies the whole disk at the hypervisor, so it brings the entire machine back in minutes, including the parts you forgot to back up. That makes it the right tool for one specific job: the upgrade broke the server and you want it back the way it was twenty minutes ago.

It is a poor tool for everything else. The granularity is the whole machine, so recovering one deleted table means restoring a whole server somewhere and digging the table out of it. Retention is usually short. The copies normally live in the same provider account as the server, so a lost account takes the server and its snapshots at once. And a snapshot of a running machine catches the database mid-write, so the database performs crash recovery on first start and any transaction still in flight is gone.

Use both. The snapshot is the undo button for an upgrade window. The dump is the copy that survives a deleted account. how snapshots and backups differ works through which failures each one actually covers. That same backup directory is also what turns moving a stack to a new VPS into a routine job instead of a rebuild from memory.

What goes wrong, and what you will see

The volumes flag on down. docker compose down -v removes the named volumes the file declares, and Compose confirms it with a line reading Volume myapp_db_data Removed. There is no undo. Plain docker compose down leaves them alone. Type the long form, docker compose down --volumes, so the destructive flag is a word you had to spell out.

A dump with no magic string. pg_restore: error: did not find magic string in file header means the file is not an archive. The usual cause is a missing -T on docker compose exec, because with a TTY attached the stream is translated on its way to your shell and the binary dump arrives damaged. Take the dump again with -T, then check the first five bytes with head -c 5.

A password that will not change. FATAL: password authentication failed for user "appuser" after a restore means the .env and the data directory came from different moments. The image sets that password only when it creates an empty data directory, so editing .env later changes nothing inside the database. Restore the matching .env, or change the password inside the database with ALTER USER.

A second, empty volume. Docker creates a volume on demand, so docker run -v myapp_upload:/data with the s missing writes into a brand new empty volume and reports success. docker volume ls then shows both names, one of them holding nothing. Copy volume names from docker volume ls rather than typing them from memory.

A restore aimed at production. Running the restore commands in /srv/myapp instead of /srv/myapp-restore overwrites live data with the backup, and the commands look identical in both places. Check pwd before every restore command, and keep the drill in its own directory.

FAQ

Does docker compose down delete my data?

No. docker compose down removes the containers and the default network, and it leaves named volumes and bind mounts alone. docker compose down -v removes the named volumes your file declares, and that is permanent. Bind mounts are host directories, so Compose never removes them. If you want the services halted during a backup with everything else untouched, use docker compose stop instead.

Can I copy the Postgres data directory instead of running pg_dump?

Only with the container stopped. While the server runs, its files change under you and the copy can hold a mix of moments that does not replay. A file-level copy is also tied to one Postgres major version, so it will not start under a different one. Stop the container, archive the volume, start it again, and treat the result as a fast rebuild path rather than your only backup. The dump is the portable copy, and the one you restore from.

How do I upgrade Postgres to a new major version in Compose?

Changing the tag is not enough. The new server refuses to start on the old data directory and logs The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.2. Run pg_dump with the old version still running, then docker compose down, remove the database volume, set the new tag, run docker compose create for a fresh empty volume, start the database, and restore the dump into it. Keep the old dump until the new version has handled real traffic.

How often should backups run, and how long should I keep them?

Match the interval to how much work you are willing to redo. Nightly suits a personal or small-team stack, plus one extra manual backup immediately before any upgrade. For retention, keep enough history to cover damage you did not notice at once, since a corrupted table found on Friday is not helped by Thursday night's copy. restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune is a reasonable starting policy. Whatever the schedule, restore from it once a quarter. Until you have done that, you do not have backups, you have files.

Do I have to stop the whole stack to take a backup?

Usually not. The database dump is consistent while the server runs, so the database needs no downtime. The volumes are the real question. If the app only adds files, such as an uploads directory, a live archive is safe enough. If it rewrites files in place, stop that one service for the length of the copy with docker compose stop app and start it again afterwards. Stopping the app while the database keeps running is normally the shortest safe window you can arrange.