Move a Docker Compose app and its volumes to a new server

Dump the database, export named volumes with a throwaway container, copy with rsync, restore under the same project name, check the data and keep a way back.

Tested on Ubuntu 24.04 LTS, Debian 13 Stable

Before you start

  • Two servers with Docker Engine and the Compose plugin
  • SSH from the old server to the new one as root
  • A short window when the app can be stopped

To move a Docker Compose app to a new server, copy the project directory (the Compose file, .env and any bind-mounted folders), dump the database from inside its container, stop the stack, export each named volume to a tar file with a throwaway container, then rebuild it on the new server under the same project name: create the volumes, unpack the archives into them, restore the dump and start the app. Don't copy /var/lib/docker or its volumes folder. It belongs to the Docker installation that made it, and a database writing to it while you copy gives you a broken copy.

The old server stays as it was until the new one has proved itself, so going back is a matter of starting it again. Only writes made on the new server after the switch need thought, and the last section covers them.

What we tested: on 7 November 2026 we ran every command below, in this order, on two separate Docker Engine 29.9.3 hosts with Compose v5.7.0, with the example app below (postgres:16-alpine, PostgreSQL 16.15, and alpine:3.22). The archives went from one host to the other over SSH with rsync 3.5.2. Both hosts were Docker-in-Docker containers on one machine rather than two servers in different places, so transfer speed and firewalls between servers weren't part of it. We also ran a second, final pass onto a new server already filled by the first, docker compose down and up, a full restart of the Docker daemon, a rollback, and the mistakes in the troubleshooting section. Other databases, DNS and certificates weren't part of the test.

What In this tutorial
The old server 203.0.113.10
The new server 198.51.100.20
The project directory, on both /opt/notes
The Compose project name notes

Run the commands as root, or put sudo in front of them.

The example app

The steps use a small app that writes all the time, so a mistake shows up as missing or repeated data. Swap in your own: what matters is that it has the three kinds of storage most apps have.

yaml
services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: notes
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_DB: notes
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "notes", "-d", "notes"]
      interval: 5s
      timeout: 5s
      retries: 5

  writer:
    image: postgres:16-alpine
    restart: unless-stopped
    user: "1000:1000"
    environment:
      PGHOST: db
      PGUSER: notes
      PGPASSWORD: ${POSTGRES_PASSWORD}
      PGDATABASE: notes
    volumes:
      - app_state:/state
      - ./uploads:/uploads
    depends_on:
      db:
        condition: service_healthy
    command:
      - sh
      - -c
      - |
        psql -qc 'CREATE TABLE IF NOT EXISTS notes (id serial PRIMARY KEY, body text, at timestamptz DEFAULT now())'
        while true; do
          n=$$(( $$(cat /state/counter 2>/dev/null || echo 0) + 1 ))
          echo $$n > /state/counter
          psql -qc "INSERT INTO notes (body) VALUES ('note $$n')"
          echo "note $$n" > /uploads/note-$$n.txt
          sleep 2
        done

volumes:
  db_data:
  app_state:

Every two seconds the writer service adds one to a counter in the app_state volume, inserts a row into PostgreSQL and writes a file into ./uploads. After a good move, all three agree and carry on from where the old server stopped.

Storage Kind How it moves
db_data Named volume, a live database A dump from inside the container, restored into a fresh volume
app_state Named volume, plain files A tar archive made by a throwaway container
./uploads Bind mount, a folder on the server rsync, keeping the numeric owners

This image keeps PostgreSQL's data in /var/lib/postgresql/data; PostgreSQL 18 images use /var/lib/postgresql instead.

List what the app keeps

Start on the old server, in the project directory. First, which Compose projects run here and where their files are:

bash
cd /opt/notes
docker compose ls
text
NAME                STATUS              CONFIG FILES
notes               running(2)          /opt/notes/compose.yaml

The NAME column is the project name. Compose takes it from the directory name unless the file sets one, and puts it in front of every volume, network and container it creates. Note it; the new server must use the same one.

Then print the file as Compose reads it, with .env already filled in, and look at every volumes: entry:

bash
docker compose config
text
    volumes:
      - type: volume
        source: app_state
        target: /state
        volume: {}
      - type: bind
        source: /opt/notes/uploads
        target: /uploads
        bind: {}
...
volumes:
  app_state:
    name: notes_app_state
  db_data:
    name: notes_db_data

type: volume is a named volume that Docker keeps under /var/lib/docker/volumes; it is exported later. type: bind is an ordinary folder on the server, shown here with its full path; it is copied with rsync. The name: lines at the end are the volumes' real names, with the project prefix.

Check the mounts the running containers really have, in case something was started by hand or the file changed since:

bash
docker volume ls
docker inspect --format '{{range .Mounts}}{{.Type}} {{.Name}} {{.Source}} -> {{.Destination}}{{println}}{{end}}' $(docker compose ps -q)
text
volume notes_db_data /var/lib/docker/volumes/notes_db_data/_data -> /var/lib/postgresql/data

volume notes_app_state /var/lib/docker/volumes/notes_app_state/_data -> /state
bind  /opt/notes/uploads -> /uploads
volume 639d540ef4c6... /var/lib/docker/volumes/639d540ef4c6.../_data -> /var/lib/postgresql/data

The last line is an anonymous volume, with a long random name. Docker made it because the writer service uses the PostgreSQL image, which declares a volume the service doesn't mount. Nothing is stored in it here, but if one of your anonymous volumes holds real data, give it a name in the Compose file before you move.

Also gather the things docker compose config points at but doesn't contain:

  • .env, and any file named by env_file: or secrets:. These hold the passwords. A database volume only reads its password when it is first created, so the restored data needs the same .env.
  • Config files the services mount, such as a Caddyfile or nginx.conf. They are bind mounts too.
  • Bind-mounted folders outside the project directory. docker compose config shows their full paths.
  • The image versions, from docker compose images. Install the same tags on the new server; a dump moves between versions, a raw database volume doesn't.

Pin the project name

If the directory on the new server might have a different name, write the project name into the Compose file now, as its first line:

yaml
name: notes

services:
  ...

With name: notes, Compose uses notes_db_data, notes_app_state and the network notes_default whatever the directory is called. Check it:

bash
docker compose config | grep 'name: notes_'
text
    name: notes_default
    name: notes_app_state
    name: notes_db_data

Without it, a copy in /srv/notes-app becomes the project notes-app and starts with new, empty volumes. The troubleshooting section shows what that looks like.

Prepare the new server

On the new server, install Docker and rsync, then make the project directory. Install the old server's SSH public key in root's authorized_keys on the new server, or copy as a user who can write to /opt.

From the old server, check that it can log in:

bash
ssh root@198.51.100.20 hostname

Plan the switch for a quiet time. From the moment the writers stop until the new server answers, the app is down: here, a few minutes plus the copy.

Stop the writers and dump the database

On the old server, stop every service that writes, but leave the database running:

bash
cd /opt/notes
docker compose stop writer

Then dump it from inside its container. A dump is consistent even while the server runs, and restores into the same or a later PostgreSQL version:

bash
mkdir -p /root/move
docker compose exec -T db pg_dump -U notes -d notes --format=custom > /root/move/notes-db.dump

Note what the old server holds at this point, to compare on the new one:

bash
docker compose exec -T db psql -U notes -d notes -tAc "select count(*), max(id) from notes"
ls uploads | wc -l
text
24|24
24

For MariaDB or MySQL, the same place takes mariadb-dump or mysqldump with --single-transaction. For an app with no dump tool, skip this step: the next one stops everything, and a stopped database's files are safe to archive.

Stop the stack and export the named volumes

Stop the rest:

bash
docker compose stop

stop keeps the containers, volumes and network, so the old server can start again exactly as it was. Don't use down -v here.

Export each named volume with a throwaway container that mounts the volume and the backup folder, as in Docker's own instructions:

bash
cd /root/move
docker run --rm -v notes_app_state:/data -v "$PWD":/backup alpine:3.22 tar czf /backup/app_state.tgz -C /data .

-C /data . stores the paths relative to the volume, so the archive unpacks into any volume. Run it as root: tar keeps each file's numeric owner, including the volume's top folder:

bash
tar tvzf app_state.tgz
text
drwxr-xr-x 1000/1000         0 2026-11-07 16:34:30 ./
-rw-r--r-- 1000/1000         3 2026-11-07 16:35:17 ./counter

You can archive the database volume the same way now that it's stopped (notes_db_data). We did, unpacked it into a new volume and started postgres:16-alpine on it: it held the same 24 rows, and PostgreSQL logged database system was shut down rather than a recovery. That only works with the same major version and image; the dump is the copy to rely on.

Copy everything to the new server

From the old server, copy the project directory, with .env and the bind-mounted uploads, and the archives:

bash
rsync -a --numeric-ids /opt/notes/ root@198.51.100.20:/opt/notes/
rsync -a /root/move/ root@198.51.100.20:/root/move/

-a keeps permissions, times and owners. --numeric-ids keeps the owners as numbers: the files in uploads belong to user 1000, which is who the writer container runs as, and on the new server there is no user 1000 at all. Without it, rsync matches owners by name, and a container's user often has no name on the server.

Check the archives arrived whole. Run this on both servers and compare:

bash
cd /root/move
sha256sum *

scp works for the archives too, but it doesn't keep owners: the copied uploads folder belonged to root, and a container running as user 1000 got Permission denied writing to it. If you use scp for a bind-mounted folder, set the owner on the new server afterwards, as in the troubleshooting section.

Recreate the volumes on the new server

On the new server, check the file, then let Compose create the network, volumes and containers without starting them:

bash
cd /opt/notes
docker compose config --quiet
docker compose create
docker volume ls
text
DRIVER    VOLUME NAME
local     notes_app_state
local     notes_db_data

Creating the volumes through Compose gives them the project's labels. A volume made by hand with docker volume create works, but Compose warns about it on every start and down -v won't remove it.

Unpack each archive into its volume with a throwaway container:

bash
cd /root/move
docker run --rm -v notes_app_state:/data -v "$PWD":/backup alpine:3.22 sh -c "cd /data && tar xzf /backup/app_state.tgz"
docker run --rm -v notes_app_state:/data alpine:3.22 ls -lan /data
text
drwxr-xr-x    2 1000     1000          4096 Nov  7 16:35 .
-rw-r--r--    1 1000     1000             3 Nov  7 16:35 counter

The owner came back as 1000, so the writer container can write to it.

Restore the database

Start only the database, wait for its health check, and restore the dump into it:

bash
cd /opt/notes
docker compose up -d --wait db
docker compose exec -T db pg_restore -U notes -d notes --exit-on-error --single-transaction < /root/move/notes-db.dump
docker compose exec -T db psql -U notes -d notes -tAc "select count(*), max(id) from notes"
text
24|24

The database is new and empty, so the dump goes in cleanly. --single-transaction with --exit-on-error means a restore that hits an error stops and leaves nothing half-done. The user notes exists because the image made it from POSTGRES_USER; if your dump's tables belong to other roles, create them first.

Start the app and check the data

bash
docker compose up -d
docker compose ps
docker compose logs --tail 20 writer

ps should show every service Up, and (healthy) for those with a health check. Then compare the data with what the old server held. After about ten seconds:

bash
docker compose exec -T db psql -U notes -d notes -tAc "select count(*), max(id), count(distinct body) from notes"
cat /var/lib/docker/volumes/notes_app_state/_data/counter
ls uploads | wc -l
text
30|30|30
30
30

The old server stopped at 24. The new one carried on with 25 to 30, with no gap and nothing repeated, in all three places. For your own app, log in and check something you know changed recently: the newest order, post or upload.

Check it survives a restart

Make sure everything comes back without you, first through Compose:

bash
docker compose down
docker compose up -d --wait

down without -v removes the containers and the network but keeps the volumes, and the data was all there after up. Then reboot the server (we restarted the Docker daemon) and check again with docker compose ps. The containers came back by themselves, thanks to restart: unless-stopped.

One thing showed up there: after a daemon restart, Docker starts the containers itself, without Compose, so depends_on and its health condition don't apply. The writer started before PostgreSQL was ready, logged Connection refused and lost one note. A real app needs to retry its database connection at startup; most do.

Switch DNS and HTTPS

Only once the new server works, point the domain at it. Lower the DNS record's TTL a day ahead, so the change spreads quickly, then change the A (and AAAA) record to 198.51.100.20. Move to a larger server walks through testing before DNS changes, and Domain points at your VPS but the site or certificate fails helps if it doesn't load.

A few things are tied to the server rather than to the volumes:

  • Certificates. A proxy such as Caddy or Traefik keeps them in a volume; move that volume like any other and it doesn't need new ones. Otherwise it gets fresh certificates after the DNS change, using the HTTP check on port 80. If you want them before the switch, use the DNS challenge.
  • Published ports. Nothing else on the new server may use the ports the stack publishes (the PORTS column of docker compose ps), and the firewall must allow them.
  • Networks. Compose creates its own networks on up. A network marked external: true, shared with a proxy from another project, has to exist first; otherwise Compose stops with network proxy declared as external, but could not be found. Create it with docker network create proxy.
  • Addresses in settings. Anything in .env or the app's settings that names the old server's IP address needs changing.

Roll back

Leave the old server stopped but intact until the new one has run your real traffic for a while. To go back, point DNS at it again and start the stack there:

bash
cd /opt/notes
docker compose start

It carries on from its own last state, not the new server's. In our test the new server had reached note 90; the old one started again at note 25, so for notes 25 to 90 the two servers held different rows under the same numbers. Anything written to the new server after the switch is not on the old one, and the two can't simply be merged.

So before rolling back, stop the app on the new server and take a dump there too. Then decide what to bring back: re-entering a few orders by hand, or running the whole move the other way. Don't switch back and forth while both servers take writes.

If you want a rehearsal before the real move, do the whole procedure once without changing DNS, check the new server, then remove the rehearsal there, before the final copy:

bash
cd /opt/notes
docker compose down -v

Run it on the new server only. Stop the new server's stack before copying again: in our first final pass the rehearsal was still running during rsync and had written 7 more files into uploads by the time it was stopped. On a cleanly stopped new server, the second pass matched the old server exactly.

Troubleshooting

The app starts on the new server as if new: empty database, first-run screen, counters from 1. Compose used a different project name, so it made new, empty volumes instead of the ones you restored. docker volume ls shows both sets, here notes_db_data and notes-app_db_data. Stop it, remove the empty set with docker compose down -v from the wrong directory, add name: notes to the Compose file, and start again. Check the names with docker compose config | grep 'name: notes_' first.

Permission denied writing to a volume or bind-mounted folder. The files belong to a different user than the one the container runs as. Find that user with docker compose exec writer id; here it prints uid=1000 gid=1000. Copy with rsync -a --numeric-ids and archive as root so the numbers survive, or set them afterwards: chown -R 1000:1000 /opt/notes/uploads. A brand-new named volume belongs to root, which is also why a fresh volume can fail the same way.

volume "notes_app_state" already exists but was not created by Docker Compose. Use external: true to use an existing volume. The volume was created with docker volume create. Compose still uses it, so the data is fine; down -v skips it (Volume was not created by Docker Compose). To avoid it, create volumes with docker compose create before unpacking into them.

pg_restore: error: could not execute query: ERROR: relation "notes" already exists. The database already holds tables, from a rehearsal or a first up. Nothing was changed, thanks to --single-transaction. Remove the new server's volumes with docker compose down -v, then run the create, unpack and restore steps again.

network proxy declared as external, but could not be found. The Compose file expects a shared network that the new server doesn't have yet. Create it with docker network create proxy, and start the project that owns it, such as the reverse proxy.

Connection refused in the logs right after a reboot. The app started before its database was ready; Docker doesn't wait for depends_on after a reboot. It clears once the database is up if the app retries. If it doesn't, stop the app with docker compose stop writer and start it again with docker compose up -d, and look for a retry setting in its documentation.

Next steps

Ready when you are

Order a server