Moving from Portainer to Dockhand is not a migration of Docker itself. Both tools manage the same Docker Engine through its API/socket. The real work is moving your stack definitions into Dockhand, making persistent storage explicit, verifying that each replacement container uses the correct data, and only then removing the older Portainer-managed resources.

The safe mental model is:

Docker owns the containers, networks, images, and volumes. Portainer and Dockhand are management planes that can both see the same Docker host.

That means you can run Portainer and Dockhand side by side during the transition. In fact, that is the safest approach: deploy and validate stacks in Dockhand one at a time, use Portainer to remove the now-superseded stack containers, and retire Portainer only after all applications are confirmed healthy.

Migration principles

A good migration has four phases:

  1. Inventory existing Portainer stacks and persistent storage.
  2. Migrate each stack definition and its data into a deliberate /opt/appdata/... layout.
  3. Deploy and validate the Dockhand version while retaining the old data as rollback.
  4. Clean up old Portainer stacks, networks, volumes, images, and finally Portainer itself.

The most important rule is simple:

Do not delete a Docker named volume merely because the replacement stack starts successfully.

For databases and stateful applications, confirm the expected historical data is present, test writes, restart the new stack, and take an application-consistent backup before deleting the old portainer-managed volume.

Docker volumes persist independently of containers by design. Removing a container normally does not remove a named volume; deleting a volume is the destructive operation.


Before you begin

Create a host-side data convention

For a self-hosted environment, explicit bind mounts make data ownership and backup much easier than accumulating Docker-managed volumes.

A practical layout is:

/opt/appdata/
├── application-a/
│   ├── config/
│   ├── data/
│   └── database/
├── collections/
│   └── collections-db/
├── linkwarden/
│   └── meilisearch/
├── penpot/
│   ├── assets/
│   └── postgres/
└── threadfin/
    ├── conf/
    └── temp/

The goal is that your application data exists in a known host location.

Capture a pre-migration inventory

Before removing anything, save the current Docker state:

mkdir -p ~/docker-migration-inventory

docker ps -a \
  --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' \
  | tee ~/docker-migration-inventory/containers.txt

docker volume ls \
  | tee ~/docker-migration-inventory/volumes.txt

docker network ls \
  | tee ~/docker-migration-inventory/networks.txt

docker image ls \
  | tee ~/docker-migration-inventory/images.txt

docker system df -v \
  | tee ~/docker-migration-inventory/system-df.txt

Also export a full container-to-mount map:

for c in $(docker ps -aq); do
  docker inspect "$c" \
    --format '{{.Name}}{{range .Mounts}} | {{.Type}}: {{.Name}} | {{.Source}} -> {{.Destination}}{{end}}'
done | tee ~/docker-migration-inventory/container-mounts.txt

This is useful later when you are trying to answer questions such as:

  • Which app used this old named volume?
  • Did the old PostgreSQL container mount pgdata at the expected location?
  • Is that long hexadecimal Docker volume attached to anything?
  • Which stacks used bind mounts versus Docker-managed storage?

Docker’s inspect command exposes the detailed mount metadata stored for containers, volumes, networks, and other Docker objects.


Copy and Update Stacks for Dockhand

Create the stack in Dockhand from the Portainer Compose YAML, but make storage paths explicit where appropriate.

Edit the Stack in portainer, copy the YAML into Dockhand after creating a new Stack. Specify the appropriate directory, I went with the following:

/opt/stacks/stack-name

Named volume to bind mount

An old Portainer/Compose configuration might look like this:

services:
  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

A Dockhand-oriented configuration with a host-visible data path might look like:

services:
  db:
    image: postgres:16-alpine
    volumes:
      - /opt/appdata/collections/collections-db:/var/lib/postgresql/data

The important change is:

Docker named volume:
pgdata:/var/lib/postgresql/data

Explicit host bind mount:
/opt/appdata/collections/collections-db:/var/lib/postgresql/data

The path after the colon remains the same because it is the application’s expected in-container data directory. Only the source changes.

Docker documents bind mounts as mappings from a host filesystem path into the container, while named volumes are stored and managed by Docker.

Resolve relative bind mounts

A Compose path beginning with ./ is relative to the Compose file’s parent directory or project directory:

volumes:
  - ./data/Threadfin/conf:/home/threadfin/conf
  - ./data/Threadfin/temp:/tmp/threadfin

The resolved host paths are:

<compose-project-directory>/data/Threadfin/conf
<compose-project-directory>/data/Threadfin/temp

Do not assume this is your current shell directory. Confirm it through the running container:

docker inspect threadfin \
  --format '{{range .Mounts}}{{printf "%-10s %s -> %s\n" .Type .Source .Destination}}{{end}}'

Compose resolves relative paths from the parent folder of the Compose file.

For long-term maintainability, replace relative paths with explicit paths:

volumes:
  - /opt/appdata/threadfin/conf:/home/threadfin/conf
  - /opt/appdata/threadfin/temp:/tmp/threadfin:rw

Preserve Compose project consistency

Dockhand may use a different project name from Portainer. That affects Docker-generated resource names:

Old Portainer project:
collections-db-1
collections_pgdata
collections_default

New Dockhand project:
dockhand-collections-db-1
dockhand-collections_default

This is expected. Compose project names influence container, network, and named-volume names.[do

The functional requirements are more important than matching old resource names:

  • Correct bind mount source and container destination.
  • Correct ports.
  • Correct environment variables.
  • Correct internal service hostnames such as db, redis, or frontend.
  • Correct reverse-proxy routing.
  • Correct external networks, if used.

Migrate persistent data

Stop writes before copying

For databases, search indexes, and applications with active writable state, stop the old source container before copying its data.

For example:

docker stop collections-db-1

Or stop the entire old Portainer stack through Portainer’s Stack UI.

Copying a live PostgreSQL data directory, Meilisearch index, SQLite database, or other transactional store can result in an inconsistent target. For a database, an application-consistent logical backup is even better, but a stopped same-version physical copy is suitable for a storage-path migration.

Copy with rsync archive mode

Use rsync -a as the baseline:

sudo rsync -a \
  /SOURCE/PATH/ \
  /DESTINATION/PATH/

-a means archive mode. It preserves the important basic filesystem metadata required for migrations:

  • Recursive directory copying.
  • Symbolic links.
  • Permissions.
  • Modification times.
  • Group ownership.
  • Owner information when run as root.
  • Device and special-file metadata where applicable.

Source trailing slash matters

These two commands do different things:

sudo rsync -a /source/data /destination/

Result:

/destination/data/

But this command:

sudo rsync -a /source/data/ /destination/

copies the contents of data directly into /destination/:

/destination/file1
/destination/subdirectory

For a container data directory migration, you typically want the second form:

sudo rsync -a --numeric-ids \
  /var/lib/docker/volumes/collections_pgdata/_data/ \
  /opt/appdata/collections/collections-db/

The trailing slash on _data/ prevents creating an accidental nested directory like:

/opt/appdata/collections/collections-db/_data/

Rsync gives special meaning to a trailing slash on the source directory: it tells rsync to copy the source directory’s contents.

Preview with a dry run

Before copying a large or important dataset:

sudo rsync -an --numeric-ids --itemize-changes \
  /SOURCE/PATH/ \
  /DESTINATION/PATH/

The -n option performs a dry run: it reports planned changes but writes nothing.

When the output looks correct, remove -n:

sudo rsync -a --numeric-ids --itemize-changes \
  /SOURCE/PATH/ \
  /DESTINATION/PATH/

Verify the copied data

Compare sizes:

sudo du -sh /SOURCE/PATH
sudo du -sh /DESTINATION/PATH

List metadata:

sudo ls -lah /DESTINATION/PATH

For PostgreSQL, expect files and directories like:

PG_VERSION
base/
global/
pg_wal/
pg_hba.conf
postgresql.conf

For an application configuration directory, inspect for the application’s expected config files, state databases, backups, and uploaded assets.


Deploy and audit in Dockhand

Once the data is copied and your Dockhand stack points at the new bind mount, deploy it by clicking Create and Start.

Confirm the running container mount

Do not assume the YAML deployed as intended. Inspect the new live container:

docker inspect NEW_CONTAINER_NAME \
  --format '{{range .Mounts}}{{printf "%-10s %s -> %s\n" .Type .Source .Destination}}{{end}}'

For a migrated Postgres service, you want to see:

bind       /opt/appdata/collections/collections-db -> /var/lib/postgresql/data

If you still see:

volume     /var/lib/docker/volumes/collections_pgdata/_data -> /var/lib/postgresql/data

then the old stack definition is still active, the new stack did not recreate the container, or you are inspecting the wrong container.

Check container logs and health

docker logs --tail 100 NEW_CONTAINER_NAME

For live follow mode:

docker logs -f --tail 100 NEW_CONTAINER_NAME

Check status and health:

docker ps \
  --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

For Docker healthcheck details:

docker inspect NEW_CONTAINER_NAME \
  --format '{{json .State.Health}}' | jq

Test the application, not only the container

A green container does not necessarily mean a successful migration. Verify:

  • The web UI loads through its normal URL.
  • Authentication works.
  • Existing records, documents, collections, or media are visible.
  • The application can write new test data.
  • The application survives a restart.
  • Background jobs, indexing, or scheduled tasks function.
  • The reverse proxy routes correctly.
  • Backups can run against the new data location.

Use Portainer for initial cleanup

During the transition, it is perfectly reasonable to use Portainer’s UI to remove old Portainer-managed stacks and containers. Since Portainer and Dockhand point at the same Docker Engine, deleting an old container in Portainer immediately removes it from Docker—and Dockhand will stop showing it too.

Remove old Portainer stacks first

Once a Dockhand replacement is tested:

  1. Open Portainer → Stacks.
  2. Open the old migrated stack.
  3. Confirm it is the legacy Portainer stack, not the new Dockhand workload.
  4. Select Delete this stack.
  5. Do not choose an option to remove volumes yet.
  6. Confirm deletion.

Removing the stack removes its containers and usually its project network if no longer in use. Named volumes normally remain unless explicitly selected for deletion, preserving rollback data.

Why remove stacks before volumes

Removing old stack containers:

  • Releases stale container records.
  • Makes old Compose networks eligible for removal.
  • Removes obsolete port mappings.
  • Prevents duplicate services from fighting over ports or databases.
  • Preserves named-volume rollback copies.

Do not select “Remove associated volumes,” “Remove non-persistent volumes,” or similarly worded volume-removal options until you have audited exactly what will disappear.

Container removal deletes the container’s writable layer. Named volume removal deletes the persistent data itself.


Clean networks after stack removal

Portainer-to-Dockhand migrations often leave old Compose networks behind. This is particularly relevant if you see errors like:

all predefined address pools have been fully subnetted

Docker creates a per-stack default bridge network unless told otherwise. On common default Docker address-pool configurations, a host can run out of automatically allocated subnets after roughly 31 user-defined bridge networks.

List network subnets and attachments

for n in $(docker network ls -q); do
  docker network inspect "$n" \
    --format '{{.Name}} {{range .IPAM.Config}}{{.Subnet}}{{end}} containers={{len .Containers}}'
done | sort

List only empty networks:

for n in $(docker network ls -q); do
  count=$(docker network inspect "$n" --format '{{len .Containers}}')
  if [ "$count" = "0" ]; then
    docker network inspect "$n" \
      --format '{{.Name}} {{range .IPAM.Config}}{{.Subnet}}{{end}}'
  fi
done | sort

Remove a specific old network

docker network rm OLD_STACK_default

Prune all unused networks

docker network prune

Docker will prompt for confirmation. It removes networks not connected to any container.

Do not delete the built-in networks:

bridge
host
none

Docker’s resource-pruning commands target unused resources, and unused networks can be removed independently of volumes or images.

Expand Docker’s address pool

If you routinely run many independent Dockhand stacks, configure a larger Docker-only address pool.

Example /etc/docker/daemon.json:

{
  "default-address-pools": [
    {
      "base": "10.200.0.0/16",
      "size": 24
    }
  ]
}

This lets Docker allocate up to 256 separate /24 bridge subnets, such as:

10.200.0.0/24
10.200.1.0/24
10.200.2.0/24
...
10.200.255.0/24

Choose a range that does not overlap with your LAN, VPN, VLANs, Proxmox networks, NAS network, or routed subnets.

Validate JSON:

sudo jq . /etc/docker/daemon.json

If you do not have jq installed:

python3 -m json.tool /etc/docker/daemon.json

Then restart Docker:

sudo systemctl restart docker

This briefly interrupts all containers on the host. Your stacks with:

restart: unless-stopped

or:

restart: always

should come back automatically, but this is still best done during a maintenance window.

This changes allocation behavior for newly created networks; it does not renumber existing networks. If you wish to allocate a new IP to an existing stack, stop the stack, purge the network, redeploy the stack.


Audit and remove old volumes

Unused does not mean unwanted during the first days after migration. Keep old database and configuration volumes through a conscious rollback period, then delete them individually or prune only when you are certain no detached volume is needed. Docker defines unused local volumes as those not referenced by any container.

Delete volumes individually

After your rollback period, delete individually verified old volumes:

docker volume rm collections_pgdata

If Docker refuses because a container still references it, inspect and remove the old container only after confirming it is obsolete.

Avoid volume prune too early

This command is powerful:

docker volume prune

It removes every Docker-managed local volume that is not referenced by an existing container. That includes:

  • Old migrated PostgreSQL volumes.
  • Detached rollback copies.
  • Anonymous application data volumes.
  • Volumes from stopped/removed Portainer stacks.

Prune images and build cache

Once you have removed old containers and verified active Dockhand stacks, reclaim image and build-cache space.

Remove dangling images

docker image prune

This removes dangling images—typically untagged leftovers from upgrades.

Remove all unused images

docker image prune -a

This removes images not used by any existing container. It is generally safe for persistent application data, but Docker will need to download those images again during a future deployment.

Clean build cache

docker builder prune

For a more complete cleanup:

docker builder prune -a

Review storage before and after:

docker system df -v

Docker provides separate prune commands for containers, images, networks, volumes, and builder cache, as well as the broad docker system prune command.

Avoid broad pruning at first

Do not use this as your first cleanup command:

docker system prune -a --volumes

It removes unused containers, networks, images, build cache, and—when --volumes is included—unused volumes. That can erase old rollback copies you intentionally retained after migration.[docs.docker][docs.docker]


Retire Portainer last

Once Dockhand is managing all your active stacks, Portainer can be removed. First identify its actual container and volume:

docker ps -a --filter name=portainer \
  --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

docker volume ls | grep -i portainer

Most common installations use:

Container: portainer
Volume:    portainer_data

Confirm only Portainer uses the data volume:

docker ps -a --filter volume=portainer_data \
  --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

Then remove Portainer:

docker stop portainer
docker rm portainer
docker volume rm portainer_data

Removing portainer_data permanently deletes Portainer’s users, settings, endpoint definitions, internal stack metadata, and history. It does not remove the application resources Portainer formerly managed, because those are Docker resources that Dockhand continues to see. Portainer’s own documentation describes removing the server container and its data volume as separate destructive steps.

At that point, Dockhand can remain your single management interface for Docker containers, Compose stacks, images, networks, and volumes. Dockhand’s volume interface supports inspection, browsing, deletion, and pruning of Docker volumes, including Docker resources created before Dockhand was installed.


Final migration checklist

Per stack

  • Exported or copied Compose configuration into Dockhand.
  • Recorded environment values, ports, domains, and secrets.
  • Inspected the old container’s live mounts.
  • Stopped the old stateful workload before physical data copy.
  • Copied data with rsync -a --numeric-ids.
  • Used a source trailing slash to copy directory contents correctly.
  • Updated Dockhand YAML to use explicit /opt/appdata/... bind mounts.
  • Deployed in Dockhand.
  • Confirmed the new live container mount with docker inspect.
  • Tested application read/write behavior.
  • Restarted the app successfully.
  • Removed the old Portainer stack without selecting volume deletion.
  • Retained the old volume until the rollback window ended.

Final host cleanup

  • Removed obsolete stopped containers.
  • Removed old unused Compose networks.
  • Configured a larger Docker address pool if running many independent stacks.
  • Inspected and individually removed old named volumes.
  • Pruned unused images and builder cache.
  • Removed Portainer only after all Dockhand stacks were stable.
  • Removed portainer_data only after confirming Portainer was no longer needed.
  • Saved final inventories and confirmed /opt/appdata is included in backups.

A methodical migration is slower than bulk cleanup, but it preserves the thing that actually matters: reliable application state. Once your bind mounts are explicit, your data is consolidated under /opt/appdata, and Dockhand is your only Docker control plane, future stack migrations, backups, and troubleshooting become much more straightforward.