A Docker container is disposable: what you actually migrate is the image it runs, the configuration that starts it and the data it keeps in volumes. Once you think of it that way, moving a stack to another server is a short, predictable process. In this tutorial you will migrate a Docker Compose application from an old server to a new Ubuntu 24.04 server, including custom images, named volumes and bind-mounted directories, and verify that it runs with all its data.
Prerequisites
To follow this tutorial you need:
- The old server, running the application with Docker and Docker Compose v2 (the
docker composecommand). - A new server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user with
sudoprivileges and Docker Engine installed from Docker's official repository, including thedocker-compose-pluginpackage. - SSH access from the old server to the new one.
- Enough free disk space on both servers for a compressed copy of the volumes.
The examples use a project in ~/myapp with a compose.yaml file. Replace myapp, your_user and new_server_ip with your own values. The commands assume your user can run docker without sudo (it is in the docker group).
Step 1 - Taking an inventory of the old server
Before copying anything, find out what the stack is made of. List the running Compose projects and the directory that holds each one:
docker compose ls
NAME STATUS CONFIG FILES
myapp running(3) /home/your_user/myapp/compose.yaml
From the project directory, print the fully resolved configuration. It shows every image, volume, bind mount, port and environment file:
cd ~/myapp
docker compose config
Pay attention to three things:
- Images: images from a public registry (
postgres:16,nginx:1.27) can simply be pulled again. Images you built locally (abuild:section) or pulled from a private registry must be transferred or rebuilt. - Named volumes: listed under the top-level
volumes:key. Docker stores them in/var/lib/docker/volumes/and prefixes their names with the project name. - Bind mounts: host paths such as
./data:/var/lib/appor/srv/uploads:/uploads. These are ordinary directories you copy with rsync.
List the actual volume names:
docker volume ls --filter label=com.docker.compose.project=myapp
DRIVER VOLUME NAME
local myapp_db_data
local myapp_uploads
Step 2 - Copying the project files
Copy the project directory, including compose.yaml, .env files and any bind-mounted data inside it, to the new server:
rsync -avP ~/myapp/ your_user@new_server_ip:~/myapp/
Keep the directory name the same. Compose uses it as the project name, and the project name is part of every volume name. If you must rename it, set name: myapp at the top of compose.yaml so the volume names still match.
If you have bind mounts outside the project directory, copy them to the same paths on the new server. Use sudo on both sides when they contain files owned by other users:
sudo rsync -aHAX --numeric-ids --rsync-path="sudo rsync" /srv/uploads/ your_user@new_server_ip:/srv/uploads/
This first copy runs while the application is online. You will repeat it after stopping the stack in Step 4, and only the changes will be transferred.
Step 3 - Transferring the images
On the new server, pull the images that come from a registry:
cd ~/myapp
docker compose pull
Services with a build: section are skipped by pull. You can rebuild them on the new server with docker compose build, which is the cleanest option if the Dockerfile and build context are in the project directory.
If you cannot rebuild an image (the build context is gone, or you want the exact same layers), send it from the old server with docker save and docker load. The stream goes straight through SSH, so no temporary file is needed:
docker save myapp-web:latest | gzip | ssh your_user@new_server_ip 'gunzip | docker load'
Loaded image: myapp-web:latest
Confirm on the new server that all images are present:
docker image ls
Noteimages built on an x86_64 server do not run on an arm64 server and vice versa. If the two servers have different architectures, rebuild the image on the new server instead of copying it.
Step 4 - Stopping the application and backing up volumes
To get a consistent copy of the data, stop the stack on the old server. From here until Step 6 the application is offline:
cd ~/myapp
docker compose stop
stop keeps the containers and volumes, so you can start the old stack again with docker compose start if something goes wrong.
Now archive each named volume. The command below starts a temporary Alpine container that mounts the volume read-only and writes a compressed tarball into the current directory:
mkdir -p ~/volume-backups
cd ~/volume-backups
docker run --rm -v myapp_db_data:/data:ro -v "$PWD":/backup alpine \
tar czf /backup/myapp_db_data.tar.gz -C /data .
docker run --rm -v myapp_uploads:/data:ro -v "$PWD":/backup alpine \
tar czf /backup/myapp_uploads.tar.gz -C /data .
Going through a container instead of copying /var/lib/docker/volumes directly works regardless of the storage driver and keeps file ownership inside the archive. Check the archives:
ls -lh ~/volume-backups
-rw-r--r-- 1 root root 412M Sep 25 10:12 myapp_db_data.tar.gz
-rw-r--r-- 1 root root 1.8G Sep 25 10:14 myapp_uploads.tar.gz
Tipfor databases, a logical dump is often a better migration format than the raw data directory, especially if you also upgrade the database version. For example,
docker compose exec db pg_dump -U app -Fc app > app.dumpbefore stopping the stack, andpg_restoreon the new server.
Copy the archives and run the final sync of any bind-mounted directories:
rsync -avP ~/volume-backups/ your_user@new_server_ip:~/volume-backups/
rsync -avP --delete ~/myapp/ your_user@new_server_ip:~/myapp/
Step 5 - Restoring the volumes on the new server
On the new server, create each volume with the same name and extract the archive into it:
cd ~/volume-backups
docker volume create myapp_db_data
docker run --rm -v myapp_db_data:/data -v "$PWD":/backup:ro alpine \
tar xzf /backup/myapp_db_data.tar.gz -C /data
docker volume create myapp_uploads
docker run --rm -v myapp_uploads:/data -v "$PWD":/backup:ro alpine \
tar xzf /backup/myapp_uploads.tar.gz -C /data
Check that the files are in place:
docker run --rm -v myapp_db_data:/data:ro alpine ls -la /data
When you later run docker compose up, Compose may warn that a volume already exists but was not created by Docker Compose. The warning is harmless: Compose uses the existing volume. To avoid it, you can mark the volume as external in compose.yaml, but that is not required.
Step 6 - Starting the stack on the new server
Start the application:
cd ~/myapp
docker compose up -d
Check that every service is running and healthy:
docker compose ps
NAME IMAGE SERVICE STATUS PORTS
myapp-db-1 postgres:16 db Up 20 seconds (healthy) 5432/tcp
myapp-web-1 myapp-web:latest web Up 18 seconds 0.0.0.0:8080->8080/tcp
myapp-proxy-1 nginx:1.27 proxy Up 18 seconds 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
Read the logs of the first minute to spot errors such as permission problems or a database that does not find its data:
docker compose logs --tail=50
Test the application locally before changing DNS:
curl -I http://localhost
To test it from your browser with the real domain before switching DNS, add a line new_server_ip your_domain to the hosts file of your computer, and remove it afterwards.
Step 7 - Switching traffic and cleaning up
Point the DNS records of your domain to new_server_ip. If the stack obtains TLS certificates automatically (Caddy, Traefik or certbot), it can only do so once DNS points to the new server, so expect a short delay on the first HTTPS request.
Keep the old stack stopped but not removed for a few days. When you are sure the new server has everything, remove it on the old server:
cd ~/myapp
docker compose down --volumes
Warning
--volumesdeletes the named volumes and all their data. Run it only on the old server, and only after you have verified the migration.
Troubleshooting
The database container restarts in a loop after the migration: the data directory is owned by the wrong user, or the image version is different from the old one. Use exactly the same image tag as on the old server, and check with docker compose logs db.
permission denied on bind-mounted directories: the numeric owner of the files does not match the UID the container runs as. Copy with --numeric-ids as shown, or fix ownership with sudo chown -R <uid>:<gid> /path.
Compose creates new, empty volumes: the project name on the new server differs from the old one, so the volume names do not match. Check with docker volume ls and set name: in compose.yaml or keep the same directory name.
exec format error: the image was built for a different CPU architecture. Rebuild it on the new server.
Conclusion
You moved a Docker Compose application by transferring its images, its configuration and the contents of its volumes, and brought it up on a new Ubuntu 24.04 server with the same data. To make the next migration even simpler, push your custom images to a registry, keep compose.yaml in version control, and schedule regular volume backups with the same tar technique used here.
