SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor

How to Back Up and Upgrade Docker Compose Stack

Docker Compose backup no end for login screen. Learn wetin to dump, which volumes to capture, how to prove restore, and how to upgrade safely.

Wetin backup for Docker Compose stack suppose contain

Backup for Docker Compose stack suppose get four separate things. If you lose any one of dem, app no go come back: the compose file, the .env wey dey beside am, the contents of every volume, and database dump wey database own client write. To copy database files while container dey run no be backup. Upgrade dey use that same list plus one rule: take backup before you pull, because schema migrations dey designed to move forward, and most projects no release any way to go back.

Everything wey follow assume say stack don already deploy and docker compose ps dey show say e dey run. Examples use project directory for /srv/myapp, with services wey dem name app and db. Replace dem with your own names. Commands remain generic on purpose, because the important parts, the volumes and database, dey work the same way no matter the app.

Work out wetin your stack really dey store

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

docker compose config --volumes dey print the short names of the named volumes wey your file declare. docker volume ls dey print the names wey those volumes really get for disk. The two lists no be the same, because Compose dey put project name for front: volume wey file write as db_data dey exist as myapp_db_data. Project name dey default to directory name, so if you rename the directory, stack go point to new set of empty volumes and leave the old ones there with all your data. Every command below need the real name from docker volume ls.

Bind mounts no dey show for either list. For compose file, dem be entries wey get host path for the left side of colon, ./config:/app/config. Dem be ordinary directories for host, so ordinary tools fit reach dem. Named volumes dey under /var/lib/docker/volumes/, and docker volume inspect --format '{{.Mountpoint}}' myapp_db_data dey print the exact path of one. The kind wey your stack dey use go determine how you copy am, and bind mounts against named volumes explain the trade-off complete.

Now arrange wetin you find into two groups. Some volumes dey hold state wey nothing fit recreate: uploaded files, generated keys, the database itself, and anything user type inside the app. Other ones dey hold derived data like thumbnails and search indexes, wey the app fit rebuild by itself. Backing up the second group dey use disk space and restore time, but e no give you any benefit. Redis cache volume na the clearest example: if you lose am, na one slow first request you go pay.

Back up the compose file and the .env file

Both files dey for the host side by side, and none dey inside any volume. The .env dey hold the database password, application secret, and any API tokens, so na this file dey turn a pile of volumes back into a working app. E normally dey listed for .gitignore too, which mean say plan like "my configuration dey for git" go leave out the one file wey matter pass. Keep secrets for an env file na the correct pattern, and e mean say your backup must cover am too.

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 wey the stack dey use, no be only the first one. Stack wey start with -f compose.yaml -f compose.prod.yaml need both files to come back the same way, and how multiple compose files merge decide which values actually reach the container.

One warning connect the .env with the volumes. The official Postgres image dey read POSTGRES_PASSWORD only when e dey initialise empty data directory. If you change that value later, e no change the password inside the database. If you restore last month's volume beside today's .env, the app go fail to connect with FATAL: password authentication failed for user "appuser", even though both files look correct when you inspect dem. Keep the .env and the volumes from the same time together inside the same backup.

Dump database with its own client

Database server dey write to its files constantly. A tar of /var/lib/postgresql/data wey dem take while server dey run fit copy some pages from before one write and some from after am, so the archive go hold mix of different moments wey no fit replay correctly. A dump tool dey read inside one transaction, so the file go hold one consistent moment. Na this difference dey separate backup from ordinary 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. E dey turn off TTY allocation. When TTY dey attached, Docker dey translate the output stream as e dey go your shell, and this fit corrupt binary dump. You no go know until restore fail. The single quotes matter too. Dem stop your host shell from expanding $POSTGRES_USER, so the shell inside the container go expand am instead, using the values wey compose file don already set there. -Fc dey write the custom format. E dey compress the dump as e dey write am, and later pg_restore fit select objects from inside am.

Roles and their passwords dey outside any one database, so back up those ones too:

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

Then check say the file na dump and no be error message:

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

Custom-format dump dey start with the five bytes PGDMP. File wey get zero bytes, or file wey start with pg_dump:, mean say the command fail. Shell dey create the output file before command run, so failed dump fit still leave file behind with believable name and timestamp. Na this be the most common silent backup failure.

For MariaDB or MySQL, the client dey change but the method remain the same:

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 dey give consistent dump of InnoDB tables without blocking writers. For MySQL image, the command na mysqldump and the variables na MYSQL_ROOT_PASSWORD and MYSQL_DATABASE. For current MariaDB images, mysqldump still dey work as compatibility name for mariadb-dump. Note say password wey you give for command line dey visible inside container process list for as long as dump dey run.

SQLite need its own care. The database na one file, but recent transactions fit still dey inside separate -wal file beside am. So if you copy only the .db, you go get database wey miss its newest writes. If image ship the client, sqlite3 /data/app.db ".backup '/data/app-backup.db'" go write consistent copy while app dey run. If e no get am, stop the container and copy the .db file together with its -wal and -shm companions.

If your database dey run for the host instead of inside the stack, the same commands apply without the docker compose exec prefix. Running database for Docker or on the host worth reading before your next rebuild.

Capture volumes

Named volume no get host path wey you suppose edit by hand, so mount am inside throwaway container and archive am 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 mount the volume as read-only for /data and your backup directory for /backup, then e write the archive go the host side. --rm remove the helper as soon as tar comot. The :ro important, because if you mistype tar command, e no go damage the source. -C /data . na wetin make the restore enter the correct place: e store every path relative to the volume root. If you write tar czf /backup/uploads.tar.gz /data instead, every path go get leading data/, so the restore go create /data/data inside the volume and the app go see empty directory. The archive belong to root, because tar run as root inside the container. Run sudo chown "$USER" /srv/backups/myapp/uploads.tar.gz if that dey cause problem, and read how PUID and PGID decide file ownership if the restored files come back unreadable to the app.

Run am once for every named volume. Bind mounts no need container at all: tar czf /srv/backups/myapp/config.tar.gz -C /srv/myapp/config . do the same work on the host.

Decide for each volume whether the app must stop. Live tar of a volume wey the app dey rewrite in place fit catch file halfway through a write. For uploads directory, where files dey write once and later only dey read, that risk small. For anything else, stop the service for the time wey the copy take with docker compose stop app, then docker compose start app. stop leave the containers and volumes for place, and na exactly wetin you want here. the difference between down and stop worth confirming before you type either one.

No treat tar of database volume as your database backup. The dump na the backup. Volume archive of stopped database na useful fast rebuild path and nothing more.

Operations order

  1. Copy the compose files and the .env go the backup directory.
  2. Dump the database while e still dey run.
  3. Stop the app container if its volumes dey change for the same place.
  4. Archive each named volume and each bind-mount directory.
  5. Start anything wey you stop, then confirm am with docker compose ps.
  6. Write down the image tags and digests wey the stack dey run.
  7. Copy the complete backup directory comot from this server.

Na Step 7 people dey leave till later.

Copy wey comot from the box

Backup wey dey the same disk with the stack fit protect you from your own mistake, but e no protect you from anything else. If one volume fail, server delete, or account lost, both copies go disappear at the same time. Push the directory go storage wey no dey this VPS, based on schedule, and use retention policy. restic backup from VPS cover repository setup, retention flags, and the check command, so we no need repeat dem here.

restic fit also read the dump direct from a pipe. This one keep the plaintext database off the disk completely:

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

Any tool wey you use, put the schedule inside a systemd timer or cron job. Make the job report failure somewhere wey you go see am. Backup script wey output dey go nowhere fit stop working for six months without anybody knowing.

Prove say the backup dey work with restore drill

Backup wey nobody don restore before na just hypothesis. The drill below restore am into a second stack wey dey run beside the first one, so production go continue to serve users and nothing wey you type fit reach am.

The mechanism na the project name. Compose dey take am from the directory name and stamp am on every container and volume wey e create. Copy the backup enter new directory, and the restored stack go automatically get its own volumes.

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 no go collide with the running stack, 18080:8080 instead of 8080:8080, or change the variable wey set am inside 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 suppose list the same volume names wey production get, with myapp-restore_ for front. Fill dem, start the database by itself, 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 dey drop each object before e recreate am, so you fit repeat the restore. Without am, a second run enter database wey already get those tables go stop with pg_restore: error: could not execute query: ERROR: relation "users" already exists.

Then start the remaining services and check am the way user go check am:

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 dey wait until every service report say e dey running or healthy, and e go exit non-zero if any one no ever reach that state. Na this one make the step scriptable. When service no ever become healthy, docker compose ps go show its state, and Compose healthchecks explain wetin that column dey read. Then open the app for the alternate port and log in with real account. Write one record and open one file wey dey inside volume. That pair na the proof: the dump restore, the volume restore, and both agree with each other. Drill wey only prove say login page dey render no prove anything about your data.

Tear down the drill after e pass:

docker compose down -v

Na this one place -v be the correct flag. For production directory, the same command go delete the volumes wey you dey try protect.

How to upgrade Compose stack

Read release notes for every version between the one wey you dey run and the one wey you want, then search dem for the words breaking and migration. Projects wey no support jumping across several major versions go talk am there, and migration wey refuse to run go tell you only after e don already change part of the schema.

Record wetin you dey run now before you change anything:

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

docker compose images dey list the image and tag wey each service dey use now. Digest na the only value wey name image exactly, because tag fit move point to another place anytime.

Take the backup from the sections above and copy am comot from the box. Do am even for patch release. The cheap upgrades na the ones wey people stop preparing for.

Then pin the version for the compose file, because latest no be version:

services:
  db:
    image: postgres:16.4

With image: postgres:latest, docker compose pull go fetch anything wey that tag point to today, and you no get way to name wetin you dey run yesterday. Pinned tag turn upgrade to one-line edit wey you fit read for git diff and reverse with one more edit. Pin the application image the same way, using the exact version from the project's release page.

Pull and recreate:

docker compose pull
docker compose up -d --wait

docker compose up -d compare the file with the containers wey dey run and recreate only services wey their image or configuration don change. E no touch named volumes, so the new container start with the existing data. Na the main point of the exercise, and na also the risk, because the new version first start na usually when e run schema migration.

Monitor wetin dey happen:

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

Container wey fail go show Exited (1) for the STATUS column of docker compose ps, and the reason dey for the last lines of the log. Migration errors dey loud there but invisible everywhere else. When the logs don settle, log in and use the app for one minute.

If docker compose pull stop with no space left on device, old image layers na the usual reason, and pruning unused Docker images go free the space. Do the pruning after the upgrade don prove say e dey work, not before, because na those old layers rollback fit run on quickly.

How to roll back when upgrade no go well

There are two cases, and the cost no be the same. If the new version no change the schema, rollback na one line: put the old tag back for the compose file and run docker compose up -d. The container go replace, the volumes go remain where dem dey, and the old code go read the data wey e write.

If the new version migrate the schema, the old code no fit read am again. Migrations dey written to move forward, and most projects no ship any downgrade script, so the old version go start and then fail for the first query against a column wey dem rename or drop, with errors like ERROR: column "avatar_url" does not exist. The way back na the dump wey you take before pulling: put the old tag back, remove the database volume, recreate am empty, restore the dump inside am, and start. If you no get that dump, no way back dey at all. Na why backup suppose come before the pull.

Postgres major versions na the sharpest example of this. E dey surprise people because the failure happen during the upgrade, instead of during rollback. The on-disk format dey change for every major release. Change postgres:16.4 to postgres:17.2, run docker compose up -d, and the new server no go 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 no run pg_upgrade for you. The supported way inside a Compose stack na dump, replace, restore: take dump while the old version still dey run, docker compose down, remove the database volume, set the new tag, run docker compose create for fresh empty data directory, start the database, restore the dump, then start the remaining services. Keep the old dump until the new major don handle real traffic for one day. Minor upgrades inside one major, like 16.4 to 16.9, no need any of this because the format stable across dem and the container go simply start.

VPS snapshots na backup?

Dem fit complement backup, but dem dey fail for different ways. Snapshot dey copy the whole disk for hypervisor level, so e fit bring the whole machine back within minutes, including the parts wey you forget back up. Na why e good for one specific work: upgrade spoil the server and you want am return to how e be twenty minutes ago.

E no good for every other thing. E dey cover the whole machine, so if you delete one table, you need restore the whole server somewhere, then bring the table comot from there. Retention usually short. The copies normally dey inside the same provider account with the server, so if the account loss, the server and snapshots go loss together. Also, snapshot of machine wey dey run fit catch database while e dey write, so database go perform crash recovery for first start, and any transaction wey still dey process go loss.

Use both. Snapshot na the undo button for upgrade window. Dump na the copy wey go survive deleted account. how snapshots and backups differ explain which failures each one actually cover. That same backup directory na wetin make moving a stack to a new VPS become routine work instead of rebuilding everything from memory.

Wetin fit go wrong, and wetin you go see

The volumes flag for down. docker compose down -v dey remove the named volumes wey the file declare, and Compose confirm am with line wey read Volume myapp_db_data Removed. No undo dey. Plain docker compose down leave dem as dem be. Type the long form, docker compose down --volumes, so the destructive flag na word wey you need spell out.

Dump wey no get magic string. pg_restore: error: did not find magic string in file header mean say the file no be archive. The usual cause na missing -T for docker compose exec, because when TTY dey attached, the stream dey translate as e dey go your shell and the binary dump go arrive damaged. Take the dump again with -T, then check the first five bytes with head -c 5.

Password wey no gree change. FATAL: password authentication failed for user "appuser" after restore mean say .env and the data directory come from different times. The image set that password only when e create empty data directory, so editing .env later no change anything inside the database. Restore the matching .env, or change the password inside the database with ALTER USER.

Second, empty volume. Docker dey create volume when e need am, so docker run -v myapp_upload:/data with s missing go write inside brand-new empty volume and report success. docker volume ls go then show both names, and one of dem no get anything inside. Copy volume names from docker volume ls instead of typing dem from memory.

Restore wey target production. If you run the restore commands for /srv/myapp instead of /srv/myapp-restore, e go overwrite live data with the backup, and the commands look the same for both places. Check pwd before every restore command, and keep the drill for its own directory.

FAQ

docker compose down dey delete my data?

No. docker compose down dey remove the containers and the default network, but e leave named volumes and bind mounts as dem be. docker compose down -v dey remove the named volumes wey your file declare, and that one permanent. Bind mounts na directories for the host, so Compose no dey remove dem. If you want make the services stop during backup while everything else remain untouched, use docker compose stop instead.

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

Only when the container don stop. While the server dey run, its files dey change, so the copy fit contain different moments wey no fit replay correctly. File-level copy also dey tied to one Postgres major version, so e no go start under another one. Stop the container, archive the volume, start am again, and treat the result as quick rebuild path, not your only backup. The dump na the portable copy, and na the one you restore from.

How I go upgrade Postgres to new major version for Compose?

Changing the tag no dey enough. The new server go refuse to start on the old data directory and log The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.2. Run pg_dump while the old version still dey run, then docker compose down, remove the database volume, set the new tag, run docker compose create for fresh empty volume, start the database, and restore the dump into am. Keep the old dump until the new version don handle real traffic.

How often backups suppose run, and how long I suppose keep dem?

Match the interval with how much work you ready to do again. Nightly backup fit work for personal or small-team stack, plus one extra manual backup immediately before any upgrade. For retention, keep enough history to cover damage wey you no notice immediately, because corrupted table wey you find on Friday no go get help from Thursday night copy. restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune na reasonable starting policy. Whatever schedule you use, restore from am once every quarter. Until you don do that, you no get backups; na just files you get.

I must stop the whole stack before I take backup?

Usually no. Database dump dey consistent while the server dey run, so database no need downtime. The volumes na the real question. If the app only dey add files, like an uploads directory, live archive safe enough. If e dey rewrite files in place, stop that one service for the duration of the copy with docker compose stop app and start am again afterwards. Stopping the app while the database continue to run normally na the shortest safe window wey you fit arrange.