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 compose command).
  • A new server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user with sudo privileges and Docker Engine installed from Docker's official repository, including the docker-compose-plugin package.
  • 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 (a build: 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/app or /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

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

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

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.