--- title: "How to Migrate Docker Containers to a New Server" description: "Move Docker Compose applications to a new Ubuntu 24.04 server: transfer images, back up and restore named volumes and bind mounts, and verify the stack works." category: "Server Migration" language: en source: https://cubepath.com/docs/server-migration/docker-container-migration updated: 2026-09-25 --- # How to Migrate Docker Containers to a New Server 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: ```bash docker compose ls ``` ```output 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: ```bash 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: ```bash docker volume ls --filter label=com.docker.compose.project=myapp ``` ```output 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: ```bash 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: ```bash 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: ```bash 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: ```bash docker save myapp-web:latest | gzip | ssh your_user@new_server_ip 'gunzip | docker load' ``` ```output Loaded image: myapp-web:latest ``` Confirm on the new server that all images are present: ```bash docker image ls ``` > Note: images 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: ```bash 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: ```bash 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: ```bash ls -lh ~/volume-backups ``` ```output -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 ``` > Tip: for 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.dump` before stopping the stack, and `pg_restore` on the new server. Copy the archives and run the final sync of any bind-mounted directories: ```bash 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: ```bash 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: ```bash 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: ```bash cd ~/myapp docker compose up -d ``` Check that every service is running and healthy: ```bash docker compose ps ``` ```output 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: ```bash docker compose logs --tail=50 ``` Test the application locally before changing DNS: ```bash 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: ```bash cd ~/myapp docker compose down --volumes ``` > Warning: `--volumes` deletes 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 : /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.