Moving Docker containers between servers comes down to three things: images, data volumes and environment configuration. A container itself holds no state — everything that matters lives in a volume and in .env variables, so "moving a container" in practice means moving the image and the data, then starting it again on the new host. If Docker is not installed on the new server yet, start with the basic Docker on a VPS setup, and for multi-container projects the Docker Compose article is useful too.
Method 1: docker save + docker load
This fits when there is no shared Registry between the servers and you need to hand over the image as a single file — for example, during a one-time move to a new VPS.
# On the old server -- save the image with all layers
docker save myapp:latest | gzip > myapp.tar.gz
# Transfer to the new server
scp myapp.tar.gz root@NEW_SERVER:/tmp/
# On the new server -- load the image
docker load < /tmp/myapp.tar.gz
# Confirm the image is present with the same ID
docker images | grep myapp
The downside is file size: an image with a heavy base layer (a full Ubuntu instead of Alpine, for instance) easily runs several gigabytes, and transferring it over a slow link takes hours.
Method 2: through a Docker Registry (recommended)
A Registry transfers only the missing layers, supports rolling back to a previous tag, and does not require keeping an image file on disk between servers.
# Tag and push to the Registry
docker tag myapp:latest registry.example.com/myapp:latest
docker push registry.example.com/myapp:latest
# On the new server
docker login registry.example.com
docker pull registry.example.com/myapp:latest
If you use docker-compose, it is enough to copy docker-compose.yml and the data volumes for the move — the images will be rebuilt locally or pulled from the Registry by the docker compose up -d command.
Common errors during the move
- Error response from daemon: conflict: unable to delete ... (must be forced) — the image is used by another tag or a running container; stop the containers using the image first, or add the
-fflag todocker rmi. - open /tmp/myapp.tar.gz: no such file or directory —
scpwas interrupted mid-transfer or the path is wrong; check the file size on both sides withls -laand compare asha256sumchecksum. - unauthorized: authentication required —
docker loginto the private Registry was not run on the new server, or the access token has expired. - Error: No such image: myapp:latest — a typo in the tag during
docker tag, or the image was never pushed before thepullattempt on the other server. - toomanyrequests: You have reached your pull rate limit — anonymous requests to Docker Hub are rate-limited per IP; run
docker loginwith an account or use your own Registry.
Moving volumes
The image carries only the application code and its dependencies. The database, uploaded files and logs live in a volume and are moved separately.
# Export the volume into a temporary archive
docker run --rm -v myapp_data:/data -v $(pwd):/backup \
alpine tar czf /backup/volume_backup.tar.gz -C /data .
# Import on the new server into a volume created in advance
docker volume create myapp_data
docker run --rm -v myapp_data:/data -v $(pwd):/backup \
alpine tar xzf /backup/volume_backup.tar.gz -C /data
Comparing the two methods
| Criterion | docker save/load | Docker Registry |
|---|---|---|
| What is transferred | The full image archive | Only the new layers |
| Requires a separate service | No | Yes, a Registry |
| Rolling back to a previous version | You must keep old archives manually | Just switch the tag |
| Best suited for | A one-time move of a single server | Regular deploys and several servers |
When these methods do not fit
- The container holds a stateful network connection open (a live WebSocket session, for example) — a move always breaks such connections; you need a graceful shutdown route at the application level.
- The container is tied to a host device through
--device(a GPU, a USB token) — the image will move, but part of the functionality will not work without the same device on the new server. - Data keeps changing during the volume export — if the application is not stopped, running
taragainst a live database volume can capture the file in an inconsistent state; for a database engine, move a dump produced by the database itself instead of the raw volume files.
Docker migration checklist
| Step | Status |
|---|---|
| Install Docker on the new server | ⬜ |
| Move docker-compose.yml | ⬜ |
| Move the images or set up a pull from the Registry | ⬜ |
| Move the data volumes | ⬜ |
| Update the .env files | ⬜ |
| Verify on the new server | ⬜ |
| Switch DNS | ⬜ |
Verifying the result
Before switching DNS, confirm the stack behaves on the new server the same way it did on the old one.
# All containers are up without restarts
docker compose ps
# No errors in the logs over the last 5 minutes
docker compose logs --since=5m
# The application responds on the internal port
curl -I http://127.0.0.1:8080/health
# Data matches: compare the row count in a key table
docker exec -it myapp_db psql -U app -c "SELECT count(*) FROM orders;"
If the counters and logs match the old server, switch DNS and only then stop the containers on the old machine.
Before stopping the containers on the old server, make sure all data is synchronized — especially with persistent volumes holding a database: any write made after the dump was taken is missing on the new server.