Containers are meant to be disposable, but the data they write is not. A useful Docker backup contains three things: the persistent data in volumes (including a consistent database dump), the configuration that recreates the containers (your Compose file and environment files), and, when needed, the images themselves. In this tutorial you will back up a sample Compose application on Ubuntu 24.04, automate the backup with a systemd timer and restore everything on the same or a different server.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
- Docker Engine and the Docker Compose plugin installed from Docker's official repository.
- A non-root user with
sudoprivileges who can rundockercommands. - Enough free disk space for the backups, ideally on a separate disk or with a copy to another server.
What to back up (and what not to)
| Item | Back up? | How |
|---|---|---|
| Named volumes and bind mounts with app data | Yes | tar archive through a temporary container |
| Databases | Yes | Logical dump (pg_dump, mysqldump) from the running container |
compose.yaml, .env, config files | Yes | Copy the project directory |
| Custom images you built | Only if you cannot rebuild or re-pull them | docker save |
Public images (nginx, postgres) | No | They are pulled again on restore |
Container filesystem (docker commit, docker export) | No | Anything worth keeping should live in a volume |
docker commit and docker export do not include volume data, so they are not a backup of your application. Treat containers as rebuildable and focus on volumes and configuration.
Step 1 - Creating a sample application
To have something concrete to back up, create a project with a PostgreSQL database and an Nginx service that serves files from a named volume:
sudo mkdir -p /opt/myapp
sudo chown $USER: /opt/myapp
cd /opt/myapp
nano compose.yaml
name: myapp
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: your_strong_password
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
restart: unless-stopped
web:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- uploads:/usr/share/nginx/html
restart: unless-stopped
volumes:
db-data:
uploads:
Replace your_strong_password with a real password. The top-level name: myapp fixes the project name, so the volumes are always called myapp_db-data and myapp_uploads.
Start the stack and add some data:
docker compose up -d
docker compose exec db psql -U app -d app -c "CREATE TABLE notes (id serial PRIMARY KEY, body text); INSERT INTO notes (body) VALUES ('backup test');"
docker compose exec web sh -c 'echo "<h1>Backup test</h1>" > /usr/share/nginx/html/index.html'
List the volumes:
docker volume ls --filter label=com.docker.compose.project=myapp
DRIVER VOLUME NAME
local myapp_db-data
local myapp_uploads
Step 2 - Backing up a volume manually
Docker has no built-in volume export command. The standard method is to start a short-lived container that mounts the volume read-only and a host directory, and runs tar between them:
sudo mkdir -p /var/backups/docker
docker run --rm \
-v myapp_uploads:/data:ro \
-v /var/backups/docker:/backup \
alpine tar czf /backup/myapp_uploads.tar.gz -C /data .
Check the archive contents:
tar tzf /var/backups/docker/myapp_uploads.tar.gz
./
./index.html
./50x.html
This works for any volume whose content is not changing while you copy it. It is not safe for a running database: copying PostgreSQL or MySQL data files while the server writes to them can produce a backup that does not start.
Step 3 - Backing up the database with a dump
For databases, use the database's own dump tool inside the running container. It produces a consistent snapshot without stopping the service. For PostgreSQL, use the custom format, which is compressed and supports selective restores:
docker compose -f /opt/myapp/compose.yaml exec -T db pg_dump -U app -d app -Fc > /tmp/app.dump
ls -lh /tmp/app.dump
-rw-rw-r-- 1 your_user your_user 2.1K Sep 25 10:00 /tmp/app.dump
The -T flag disables the pseudo-terminal, which is required when redirecting binary output to a file. For MySQL or MariaDB the equivalent is mysqldump --single-transaction (or mariadb-dump) run the same way.
Step 4 - Saving custom images
If you run images that you built locally and cannot rebuild or pull from a registry, save them to a tar file:
docker save -o /var/backups/docker/myimage.tar your_image:tag
Restore them later with docker load -i myimage.tar. For public images, recording the exact tag in compose.yaml is enough; pinning versions (postgres:16 rather than postgres:latest) ensures a restore gets a compatible version.
Step 5 - Writing a backup script
Combine the previous steps in a script that creates a dated directory, dumps the database, archives the non-database volumes, copies the project configuration and removes backups older than seven days:
sudo nano /usr/local/sbin/docker-backup
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR=/opt/myapp
BACKUP_ROOT=/var/backups/docker/myapp
RETENTION_DAYS=7
VOLUMES=(myapp_uploads)
STAMP=$(date +%Y-%m-%d_%H%M)
DEST="$BACKUP_ROOT/$STAMP"
mkdir -p "$DEST"
# Consistent database dump from the running container
docker compose -f "$PROJECT_DIR/compose.yaml" exec -T db \
pg_dump -U app -d app -Fc > "$DEST/app.dump"
# File volumes, archived through a temporary container
for vol in "${VOLUMES[@]}"; do
docker run --rm \
-v "$vol":/data:ro \
-v "$DEST":/backup \
alpine tar czf "/backup/$vol.tar.gz" -C /data .
done
# Configuration needed to recreate the containers
cp "$PROJECT_DIR/compose.yaml" "$DEST/"
if [[ -f "$PROJECT_DIR/.env" ]]; then
cp "$PROJECT_DIR/.env" "$DEST/"
fi
chmod -R go-rwx "$DEST"
# Remove old backups
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -exec rm -rf {} +
echo "Backup written to $DEST"
The database volume myapp_db-data is deliberately not in VOLUMES, because the dump already covers it. The chmod step matters because the backup contains the database and possibly secrets from .env.
Make the script executable and run it once:
sudo chmod 750 /usr/local/sbin/docker-backup
sudo /usr/local/sbin/docker-backup
sudo ls -l /var/backups/docker/myapp/*/
Backup written to /var/backups/docker/myapp/2026-09-25_1000
total 12
-rw------- 1 root root 2143 Sep 25 10:00 app.dump
-rw------- 1 root root 472 Sep 25 10:00 compose.yaml
-rw------- 1 root root 301 Sep 25 10:00 myapp_uploads.tar.gz
Step 6 - Scheduling daily backups with systemd
A systemd timer runs the script every night and logs its output to the journal. Create the service unit:
sudo nano /etc/systemd/system/docker-backup.service
[Unit]
Description=Back up Docker application data
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-backup
Then the timer:
sudo nano /etc/systemd/system/docker-backup.timer
[Unit]
Description=Daily Docker backup
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true runs a missed backup at the next boot if the server was off at 03:00. Enable the timer and test the service once:
sudo systemctl daemon-reload
sudo systemctl enable --now docker-backup.timer
sudo systemctl start docker-backup.service
systemctl list-timers docker-backup.timer
journalctl -u docker-backup.service -n 5 --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sat 2026-09-26 03:07:41 UTC 16h left Fri 2026-09-25 10:05:12 UTC 3s ago docker-backup.timer docker-backup.service
Sep 25 10:05:12 server docker-backup[4210]: Backup written to /var/backups/docker/myapp/2026-09-25_1005
Sep 25 10:05:12 server systemd[1]: docker-backup.service: Deactivated successfully.
A backup on the same disk does not protect you against disk failure or losing the server. Copy /var/backups/docker to another machine or to object storage, for example with rsync over SSH or restic, as part of the same schedule.
Step 7 - Restoring a volume
To restore a file volume, stop the services that use it, empty it and extract the archive. Pick the backup directory you want:
BACKUP=/var/backups/docker/myapp/2026-09-25_1000
cd /opt/myapp
docker compose stop web
docker run --rm \
-v myapp_uploads:/data \
-v "$BACKUP":/backup:ro \
alpine sh -c 'find /data -mindepth 1 -delete && tar xzf /backup/myapp_uploads.tar.gz -C /data'
docker compose start web
curl http://localhost:8080
<h1>Backup test</h1>
find /data -mindepth 1 -delete removes everything inside the volume, including hidden files, so the result matches the backup exactly.
Step 8 - Restoring the database
pg_restore with --clean --if-exists drops the objects in the dump and recreates them. Stop services that write to the database first, then pipe the dump into the container:
sudo cat "$BACKUP/app.dump" | docker compose exec -T db pg_restore -U app -d app --clean --if-exists
docker compose exec db psql -U app -d app -c "SELECT * FROM notes;"
id | body
----+-------------
1 | backup test
(1 row)
Step 9 - Restoring on a new server
To move the application to a new server, or rebuild after losing the old one:
- Install Docker Engine and the Compose plugin on the new server.
- Copy the backup directory to it. The files are readable only by root, so run
rsyncwithsudoon the old server, for examplesudo rsync -a -e "ssh -i /home/your_user/.ssh/id_ed25519" /var/backups/docker/myapp/2026-09-25_1000/ your_user@your_server_ip:~/restore/. - Recreate the project directory and start only the database, which initializes an empty
appdatabase:
sudo mkdir -p /opt/myapp
sudo chown $USER: /opt/myapp
cp ~/restore/compose.yaml /opt/myapp/
cd /opt/myapp
docker compose up -d db
- Wait a few seconds for PostgreSQL to accept connections, then restore the dump:
docker compose exec db pg_isready -U app
docker compose exec -T db pg_restore -U app -d app --clean --if-exists < ~/restore/app.dump
- Create the remaining containers and volumes without starting them, restore the file volume and start everything:
docker compose create web
docker run --rm -v myapp_uploads:/data -v ~/restore:/backup:ro \
alpine tar xzf /backup/myapp_uploads.tar.gz -C /data
docker compose up -d
Creating the volume through docker compose create gives it the Compose project labels, so Compose manages it normally afterwards. Check the site and the database as in Steps 7 and 8.
Troubleshooting
the input device is not a TTY: you redirected output fromdocker compose execwithout-T. Add it.- Restored files are owned by the wrong user:
tarpreserves numeric owners when run as root in the container. If the application runs as a different UID on the new server, fix ownership withdocker run --rm -v vol:/data alpine chown -R uid:gid /data. pg_restore: error: ... already exists: you restored into a database with existing objects without--clean --if-exists.- Backups fill the disk: lower
RETENTION_DAYSand check the timer runs withsystemctl list-timers.
Conclusion
You backed up a Compose application in a way that survives server loss: a consistent database dump, archived file volumes, the configuration to recreate the containers and, where needed, saved images. A systemd timer runs the backup daily and removes old copies, and you tested a full restore. As next steps, send the backups off-server with restic or rsync, add a restore test to your routine, and extend VOLUMES and the dump commands for every application on the host.
