English | فارسی
A general-purpose, safe process for moving Docker Compose projects from an old server to a new one.
The guide focuses on:
- PostgreSQL
- Docker Volumes
- Media
- Compression with
gzipandzstd - Backup validation
- Transfer with
rsync - Restore
- Preventing data loss
- Final testing and cutover
So that the commands don't depend on any one specific project, we define variables first:
PROJECT_NAME="myproject"
DB_NAME="mydatabase"
DB_USER="postgres"
MEDIA_VOLUME="${PROJECT_NAME}_media_volume"
POSTGRES_VOLUME="${PROJECT_NAME}_postgres_data"
BACKUP_DIR="/backup/${PROJECT_NAME}"Every command from here on uses these variables.
Every command in this guide uses -T:
docker compose -f docker-compose.prod.yml exec -T db ...Without -T, Docker allocates a TTY and corrupts the binary output of pg_dump. The result is a file that appears to have been created but cannot be restored.
When transferring binary data,
-Tis not optional.
Before starting the backup, we need to know exactly which services and volumes we have.
docker compose -f docker-compose.prod.yml psdocker volume lsdocker system df -vImportant:
Before migrating, you must know exactly which volumes hold the database, the media, and any other persistent data.
For PostgreSQL, the primary backup method is pg_dump.
Using the custom format with -Fc is recommended:
mkdir -p "$BACKUP_DIR"
docker compose -f docker-compose.prod.yml exec -T db \
pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc \
> "$BACKUP_DIR/postgres.dump"But since we intend to transfer the backup file, it is better to compress it.
First approach:
docker compose -f docker-compose.prod.yml exec -T db \
pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc \
| gzip -9 \
> "$BACKUP_DIR/postgres.dump.gz"The structure:
PostgreSQL
↓
pg_dump -Fc
↓
gzip -9
↓
postgres.dump.gz
Always test the compressed file itself:
gzip -t "$BACKUP_DIR/postgres.dump.gz"If the command finishes without error, the gzip file is intact.
gzip -dc "$BACKUP_DIR/postgres.dump.gz" \
| pg_restore --list > /dev/nullBeyond confirming the gzip file is intact, this test verifies that the dump's contents are readable by pg_restore.
For large backups, zstd is an excellent choice.
docker compose -f docker-compose.prod.yml exec -T db \
pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc \
| zstd -T0 -10 -f \
-o "$BACKUP_DIR/postgres.dump.zst"-T0
Use all available CPU threads.
-10
Compression level.
-f
Allow overwriting an existing output file. Without it, re-running the backup stops with an error.
For more speed you could use, for example:
zstd -T0 -5And for better compression:
zstd -T0 -10is a good choice.
First we test the zstd file itself:
zstd -t "$BACKUP_DIR/postgres.dump.zst"Then we check the dump structure:
zstd -dc "$BACKUP_DIR/postgres.dump.zst" \
| pg_restore --list > /dev/nullSo for PostgreSQL with zstd we have two tests:
postgres.dump.zst
│
├── zstd -t
│
└── pg_restore --list
| Feature | gzip | zstd |
|---|---|---|
| Compression | Good | Very good |
| Compression speed | Moderate | Usually faster |
| Decompression speed | Good | Very good |
| Multi-thread | Limited | Yes |
| Suitable for large backups | Good | Excellent |
| Validation | gzip -t |
zstd -t |
For large backups, my recommendation:
pg_dump
↓
zstd -T0 -10
↓
postgres.dump.zst
Media typically includes:
- Images
- PDFs
- User files
- Attachments
- Uploaded files
To back it up, we can mount the volume into a temporary container:
docker run --rm \
-v "$MEDIA_VOLUME":/data:ro \
alpine \
tar -cf - -C /data . \
| zstd -T0 -10 -f \
-o "$BACKUP_DIR/media.tar.zst"Note: zstd runs here on the host, not inside the container. That is why there is no need to mount $BACKUP_DIR into the container, and why zstd must be installed on the host.
The structure:
Docker Volume
↓
tar
↓
zstd
↓
media.tar.zst
Using :ro matters, because the backup container must not be able to modify the original data.
First we check the zstd file:
zstd -t "$BACKUP_DIR/media.tar.zst"Then we check the archive itself:
zstd -dc "$BACKUP_DIR/media.tar.zst" \
| tar -tf - > /dev/nullSo:
media.tar.zst
│
├── zstd -t
│
└── tar -tf
Both must run without error.
If needed, we can also back up the PostgreSQL volume:
docker run --rm \
-v "$POSTGRES_VOLUME":/data:ro \
alpine \
tar -cf - -C /data . \
| zstd -T0 -10 -f \
-o "$BACKUP_DIR/postgres-volume.tar.zst"And then:
zstd -t "$BACKUP_DIR/postgres-volume.tar.zst"But keep in mind:
A raw volume backup is not a replacement for
pg_dump.
For a logical PostgreSQL backup, the primary method is still:
pg_dump
A volume backup is more useful as a supplementary backup or for disaster recovery.
Once the backup files have been confirmed structurally sound, we hash them:
cd "$BACKUP_DIR"
sha256sum *.zst > SHA256SUMSIf you used gzip instead:
sha256sum *.gz > SHA256SUMSWriting *.gz *.zst together is not correct; if either pattern matches no files, sha256sum errors out.
Then:
sha256sum -c SHA256SUMSThe output should look like this:
postgres.dump.gz: OK
postgres.dump.zst: OK
media.tar.zst: OK
postgres-volume.tar.zst: OK
Naturally, only the files that actually exist will appear in the output.
A trustworthy backup must have at least three layers of validation.
For gzip:
gzip -t postgres.dump.gzFor zstd:
zstd -t postgres.dump.zstFor PostgreSQL:
zstd -dc postgres.dump.zst \
| pg_restore --list > /dev/nullFor media:
zstd -dc media.tar.zst \
| tar -tf - > /dev/nullsha256sum -c SHA256SUMSSo:
Compression OK
+
Archive/Dump OK
+
SHA256 OK
↓
Backup Ready
After validation, we transfer the backup to the new server:
rsync -avhP \
"$BACKUP_DIR/" \
user@NEW_SERVER:"$BACKUP_DIR/"The -P option means:
- Progress is displayed.
- Interrupted transfers resume more easily.
You may see an error such as:
failed to set times ... Operation not permitted
On its own, this error does not mean the files are corrupt.
After the transfer, always verify the hashes.
On the new server, we first have to redefine the same variables from section 1; the shell variables from the old server do not exist here:
PROJECT_NAME="myproject"
DB_NAME="mydatabase"
DB_USER="postgres"
MEDIA_VOLUME="${PROJECT_NAME}_media_volume"
POSTGRES_VOLUME="${PROJECT_NAME}_postgres_data"
BACKUP_DIR="/backup/${PROJECT_NAME}"
cd "$BACKUP_DIR"Then:
sha256sum -c SHA256SUMSThen the compressed files:
gzip -t postgres.dump.gzor:
zstd -t postgres.dump.zstand:
zstd -t media.tar.zstThen the dump structure:
zstd -dc postgres.dump.zst \
| pg_restore --list > /dev/nullAnd the media structure:
zstd -dc media.tar.zst \
| tar -tf - > /dev/nullThis step proves the backup was not corrupted or altered in transit.
First transfer the project and check the configuration:
docker compose -f docker-compose.prod.yml configThen bring up the database:
docker compose -f docker-compose.prod.yml up -d dbThis also creates the required volume.
If our backup is:
postgres.dump.zst
then:
zstd -dc "$BACKUP_DIR/postgres.dump.zst" \
| docker compose -f docker-compose.prod.yml exec -T db \
pg_restore \
-U "$DB_USER" \
-d "$DB_NAME" \
--clean \
--if-existsBefore using:
--clean
we must be certain the target database is genuinely the one meant to be replaced.
If we used:
postgres.dump.gz
then:
gzip -dc "$BACKUP_DIR/postgres.dump.gz" \
| docker compose -f docker-compose.prod.yml exec -T db \
pg_restore \
-U "$DB_USER" \
-d "$DB_NAME" \
--clean \
--if-existsThe default alpine image does not include zstd:
docker run --rm alpine sh -c 'command -v zstd'The output is empty. So we install zstd first:
docker run --rm \
-v "$MEDIA_VOLUME":/data \
-v "$BACKUP_DIR":/backup:ro \
alpine \
sh -c 'apk add --no-cache zstd && \
zstd -dc /backup/media.tar.zst | tar -xf - -C /data'Note: apk add requires internet access on the new server.
Then we check the volume size:
docker run --rm \
-v "$MEDIA_VOLUME":/data:ro \
alpine \
du -sh /dataThe size should roughly match the old server.
After the restore:
docker compose -f docker-compose.prod.yml up -dThen:
docker compose -f docker-compose.prod.yml psAll of the main services should be running.
docker compose -f docker-compose.prod.yml exec redis redis-cli pingExpected output:
PONG
docker compose -f docker-compose.prod.yml logs --tail=100 celery_workerand:
docker compose -f docker-compose.prod.yml logs --tail=100 celery_beatThere should be no connection errors to Redis or PostgreSQL.
Logs:
docker compose -f docker-compose.prod.yml logs --tail=100 appIf you have services such as myproject_app:
docker compose -f docker-compose.prod.yml logs --tail=100 myproject_appWe also need to verify the application is listening on the right interface.
In Docker it should usually be:
0.0.0.0:8000
not:
127.0.0.1:8000
For example:
docker compose -f docker-compose.prod.yml exec myproject_app \
sh -c 'env | sort | grep -Ei "gunicorn|bind|worker|config"'Before reloading:
nginx -tIf it succeeds:
systemctl reload nginxAlso check the logs:
docker compose -f docker-compose.prod.yml logs --tail=100 nginxOr, if Nginx is installed on the host:
tail -f /var/log/nginx/error.logBefore running Certbot, make sure DNS is correct for all domains.
For example:
dig +short example.com
dig +short www.example.comAnd for subdomains as well:
dig +short panel.example.com
dig +short api.example.comIf a domain does not exist, Certbot may return:
NXDOMAIN
So the correct order is:
DNS
↓
Server
↓
Nginx
↓
HTTP
↓
Certbot / SSL
Containers being in a running state is not enough.
We have to test the real application.
- Login
- Logout
- JWT / Session
- Older records
- Users
- Core data
- Record counts
- Older images
- PDFs
- Uploaded files
- Download
- New upload
- Celery Worker
- Celery Beat
- Redis
- Nginx
- HTTPS
- Domain
- API
For extra confidence, compare a few metrics before and after:
Number of users
Number of core records
Number of files
Media size
Database size
For example:
Old Server
-----------
Users: X
Records: Y
Media: Z GB
New Server
-----------
Users: X
Records: Y
Media: Z GB
The goal is that it is not merely:
Container = Running
but rather:
Data = Correct
The most important step is taking a correct final backup right before cutover.
The recommended process:
Old Server
↓
Stop Writes / Maintenance
↓
Final pg_dump
↓
Final Media Backup
↓
Compression Test
↓
Archive/Dump Test
↓
SHA256
↓
Transfer
↓
New Server
↓
SHA256 Validation
↓
Restore
↓
Application Test
↓
DNS Cutover
This minimizes the gap between the final backup and the moment traffic moves over.
Copying the volume is not a replacement for pg_dump.
For PostgreSQL:
pg_dump
=
the primary logical backup
and:
postgres volume backup
=
supplementary backup / disaster recovery
The fact that a backup file exists and has a reasonable size does not mean it is intact.
For example:
ls -lh postgres.dump.zstis not enough.
You must run:
zstd -t postgres.dump.zstand:
pg_restore --listand:
sha256sum -c SHA256SUMSAfter a successful migration, do not delete the old server immediately.
It is better to keep it around for a while in case you need to roll back.
Items such as:
DATABASE_URL
SECRET_KEY
REDIS_URL
ALLOWED_HOSTS
CORS
JWT configuration
Storage configuration
API keys
all need to be checked.
- All Docker Compose projects have been identified.
- All important volumes have been identified.
- A PostgreSQL
pg_dumphas been taken. - The dump has been compressed with gzip or zstd.
-
gzip -torzstd -tsucceeded. -
pg_restore --listsucceeded. - A media backup has been taken.
-
zstd -t media.tar.zstsucceeded. -
tar -tfsucceeded. -
SHA256SUMShas been created. - SHA256 is
OKon the old server. - The backup has been transferred with
rsync. - SHA256 is
OKon the new server. - The compression test succeeded on the new server.
- PostgreSQL has been restored.
- Media has been restored.
- Redis responds with
PONG. - Celery Worker is healthy.
- Celery Beat is healthy.
- The application is healthy.
- Nginx is healthy.
- SSL is healthy.
- DNS is correct.
- Login has been tested.
- The API has been tested.
- Older media files have been tested.
- Record counts have been compared.
- The old server has been kept for rollback.
The full process can be summarized in this pipeline:
OLD SERVER
│
▼
┌───────────┐
│ Backup │
└─────┬─────┘
│
┌─────────────┴─────────────┐
▼ ▼
PostgreSQL Media
│ │
pg_dump -Fc tar
│ │
gzip / zstd zstd
│ │
└─────────────┬─────────────┘
▼
Integrity Tests
│
┌──────────┴──────────┐
▼ ▼
gzip -t zstd -t
│ │
└──────────┬──────────┘
▼
Archive Tests
│
┌───────┴───────┐
▼ ▼
pg_restore --list tar -tf
│ │
└───────┬───────┘
▼
SHA256
│
▼
rsync
│
▼
NEW SERVER
│
▼
SHA256 Validation
│
▼
Restore
│
▼
Docker Compose
│
▼
Application Testing
│
▼
DNS Cutover
│
▼
Monitor
│
▼
Rollback Window
The existence of a backup file does not mean the backup is intact.
A trustworthy backup must be:
Compression OK
+
Archive/Dump OK
+
SHA256 OK
+
Restore OK
+
Application Test OK
For that reason, always keep these three commands in mind:
gzip -t backup.dump.gzzstd -t backup.dump.zstsha256sum -c SHA256SUMSAnd to be sure about the backup's contents:
zstd -dc backup.dump.zst \
| pg_restore --list > /dev/nullAnd for media:
zstd -dc media.tar.zst \
| tar -tf - > /dev/nullThis pattern works for any Docker Compose project and is not tied to any particular project name.