Skip to content

Move or restore a Docker Compose app onto a new machine

Back up a Compose stack (compose files, .env, named volumes and bind-mounted folders) and bring it back on a blank machine with its data intact, without the 'it started fresh and empty' surprise.

advancedUpdated 2026-11-02

A Compose app is more than its compose.yaml. Copy only that file to a new machine, run docker compose up, and you get a brand-new, empty app: no users, no database, no settings. The data lives in places the compose file only points at.

This guide moves everything across and proves the restore worked before you touch the old machine. The examples use a project called myapp in ~/myapp.

Stop if: you’re about to run docker compose down -v. The -v deletes named volumes, which are your data. Also avoid plain down until the end: Docker’s docs say it removes the containers, so anything an app wrote inside a container (not to a volume) is gone with them.

Step 1: Inventory on the old machine (before stopping anything)

Record exactly what’s running, so nothing is left behind and the new machine runs the same thing.

cd ~/myapp
umask 077                                    # backups contain passwords: keep them private
mkdir -p ~/migrate
docker compose ls                            # the real PROJECT NAME (may not match the folder)
docker compose config > ~/migrate/resolved-compose.yaml   # the fully merged config actually in use
docker compose images > ~/migrate/images.txt              # versions you're running now
docker compose ps -q | xargs docker inspect \
  --format '{{.Name}}{{range .Mounts}}{{println}}  {{.Type}}  {{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}} -> {{.Destination}}{{end}}' \
  | tee ~/migrate/mounts.txt

Read mounts.txt carefully. It’s your checklist:

  • volume lines are named volumes (myapp_db-data) or, if the name is a long random string, anonymous volumes. Those are easy to lose, so back them up by name like the rest.
  • bind lines are host folders. Some may be outside ~/myapp (e.g. /srv/photos). Each one needs copying.
  • An important path inside a container with no mount (for example an app that saves to /app/data but you never mapped it) lives only in that container. Copy it out now with docker cp <container>:/app/data ~/migrate/. Better still, add a volume for it on the new machine.

Also note how you start it: extra -f files, --profiles, -p or COMPOSE_PROJECT_NAME. The new machine must use the same ones.

Pin versions: in images.txt, note the exact tag (or digest) of each image. If the compose file says :latest, change it to that exact version before the first start on the new machine. Otherwise you may get a newer database that upgrades your data on first launch.

Step 2: Make app-level dumps, then stop

If there’s a database, take its own dump while it’s still running. This is the most portable backup there is:

docker compose exec -T db pg_dump -U myuser mydb > ~/migrate/mydb.sql          # PostgreSQL
# docker compose exec -T db mysqldump -u root -p mydb > ~/migrate/mydb.sql     # MySQL/MariaDB

Then stop the app but keep its containers:

docker compose stop -t 60        # give databases time to shut down cleanly
docker compose ps -a             # all should say "Exited (0)"; investigate anything else

If something else also writes to the same folders or volumes (another stack, a backup job), stop that too.

Step 3: Back up every volume and folder on your list

Named and anonymous volumes. Docker’s documented pattern is a throwaway container that tars the volume. Edit VOLUMES to match mounts.txt exactly.

( set -euo pipefail                      # the ( ) keeps a failure from closing your terminal
  cd ~/migrate
  VOLUMES="myapp_db-data myapp_uploads"
  : > volumes.list
  for v in $VOLUMES; do
    docker run --rm -v "$v":/data:ro -v "$PWD":/backup alpine \
      tar czf "/backup/vol-$v.tgz" -C /data .
    echo "$v" >> volumes.list
  done ) && echo "ALL VOLUMES BACKED UP"

If you don’t see ALL VOLUMES BACKED UP, one failed. Fix it before going on.

Bind-mounted folders, including any outside the project, and the project folder itself (compose files, .env, env_files, secrets):

sudo tar --numeric-owner --acls --xattrs -czpf ~/migrate/project.tgz -C ~ myapp
sudo tar --numeric-owner --acls --xattrs -czpf ~/migrate/bind-srv-photos.tgz -C / srv/photos   # one per outside folder
sudo chown -R "$USER" ~/migrate

--numeric-owner keeps the exact user and group IDs that apps inside containers depend on.

Finally, record checksums and confirm every archive is readable:

cd ~/migrate && sha256sum *.tgz *.sql > SHA256SUMS
for f in *.tgz; do tar tzf "$f" > /dev/null && echo "OK $f"; done

Step 4: Move it and verify

Copy ~/migrate by any private route: scp -r ~/migrate newhost:~/, an external drive, or the NAS. These files contain passwords and databases, so don’t leave them on shared storage longer than needed. On the new machine:

cd ~/migrate && sha256sum -c SHA256SUMS      # every line must say OK

Step 5: Restore on the new machine

  1. Install Docker and the Compose plugin (docker compose version should work). If the new machine has a different CPU type (Intel → Raspberry Pi or Apple Silicon), check every image is published for it.
  2. Restore the project and bind folders to the same paths:
    cd ~ && sudo tar --numeric-owner --acls --xattrs -xzpf ~/migrate/project.tgz
    sudo tar --numeric-owner --acls --xattrs -xzpf ~/migrate/bind-srv-photos.tgz -C /
  3. Make sure the project name matches. Compose names a volume <project>_<volume>, and the project defaults to the folder name. Restore into a different folder and Compose quietly makes new, empty volumes. Adding name: myapp at the top of the compose file pins it. Note that -p or COMPOSE_PROJECT_NAME still override it, so use the same ones you noted in Step 1.
  4. Pin image versions (Step 1), then let Compose create the volumes without starting anything. Compose labels the volumes it creates, and it warns about volumes it didn’t create (a Docker forum answer covers this). Volumes marked external: true aren’t created by Compose: make those yourself first with docker volume create <name>.
    cd ~/myapp
    docker compose create
  5. Fill each volume. It’s emptied first, because creating a container can copy files from the image into a new volume, and you want exactly your data, not a mix:
    ( set -euo pipefail
      while read -r v; do
        docker volume inspect "$v" > /dev/null        # stops here if the name doesn't exist
        docker run --rm -v "$v":/data -v ~/migrate:/backup alpine \
          sh -c "find /data -mindepth 1 -delete && tar xzf /backup/vol-$v.tgz -C /data"
      done < ~/migrate/volumes.list ) && echo "ALL VOLUMES RESTORED"
  6. Start it and watch the logs:
    docker compose up -d
    docker compose logs -f --tail=50

Step 6: Prove it’s really your data

  • Log in with an existing account. A first-run “create admin” screen means it’s reading empty volumes: check docker volume ls and the project name.
  • Check each data store, not just one. Open something recent from every app or service in the stack (the latest photo, the newest record, recent settings).
  • Bind folders kept their owners: ls -ln /srv/photos shows the same numbers as on the old machine. Apps running as a specific user ID fail with “permission denied” if they changed.
  • Run the app’s own health or integrity check if it has one (many self-hosted apps have a maintenance or “check database” option).

Only when everything passes should you retire the old machine. Now docker compose down there is fine. Keep ~/migrate (somewhere private) for a few weeks.

Common snags

  • The database got upgraded by accident because :latest pulled a newer version: don’t just switch the tag back. The data may already be converted to the new format. Restore the volume again from your .tgz (or the .sql dump), pin the old version, then upgrade properly following the app’s instructions.
  • Ports already in use on the new machine: sudo ss -ltnp (Linux) shows what’s holding them.
  • Rootless Docker or user-namespace remapping on one machine but not the other changes how file owners map. Use the same setup on both, or fix ownership after restoring.
  • Copying /var/lib/docker wholesale seems quicker, but a Docker forum thread notes it only works when both machines use the same storage driver. The volume-by-volume method above is the portable one.

Sources