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:
- Inventory existing Portainer stacks and persistent storage.
- Migrate each stack definition and its data into a deliberate
/opt/appdata/...layout. - Deploy and validate the Dockhand version while retaining the old data as rollback.
- 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.txtAlso 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.txtThis 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
pgdataat 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-nameNamed 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/dataThe important change is:
Docker named volume:
pgdata:/var/lib/postgresql/data
Explicit host bind mount:
/opt/appdata/collections/collections-db:/var/lib/postgresql/dataThe 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/threadfinThe resolved host paths are:
<compose-project-directory>/data/Threadfin/conf
<compose-project-directory>/data/Threadfin/tempDo 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:rwPreserve 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_defaultThis 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, orfrontend. - 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-1Or 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/subdirectoryFor 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/PATHList metadata:
sudo ls -lah /DESTINATION/PATHFor PostgreSQL, expect files and directories like:
PG_VERSION
base/
global/
pg_wal/
pg_hba.conf
postgresql.confFor 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/dataIf you still see:
volume /var/lib/docker/volumes/collections_pgdata/_data -> /var/lib/postgresql/datathen 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_NAMEFor live follow mode:
docker logs -f --tail 100 NEW_CONTAINER_NAMECheck 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}}' | jqTest 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:
- Open Portainer → Stacks.
- Open the old migrated stack.
- Confirm it is the legacy Portainer stack, not the new Dockhand workload.
- Select Delete this stack.
- Do not choose an option to remove volumes yet.
- 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 subnettedDocker 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 | sortList 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 | sortRemove a specific old network
docker network rm OLD_STACK_defaultPrune all unused networks
docker network pruneDocker will prompt for confirmation. It removes networks not connected to any container.
Do not delete the built-in networks:
bridge
host
noneDocker’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/24Choose 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.jsonIf you do not have jq installed:
python3 -m json.tool /etc/docker/daemon.jsonThen restart Docker:
sudo systemctl restart dockerThis briefly interrupts all containers on the host. Your stacks with:
restart: unless-stoppedor:
restart: alwaysshould 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_pgdataIf 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 pruneIt 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 pruneThis removes dangling images—typically untagged leftovers from upgrades.
Remove all unused images
docker image prune -aThis 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 pruneFor a more complete cleanup:
docker builder prune -aReview storage before and after:
docker system df -vDocker 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 --volumesIt 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 portainerMost common installations use:
Container: portainer
Volume: portainer_dataConfirm 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_dataRemoving 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_dataonly after confirming Portainer was no longer needed. - Saved final inventories and confirmed
/opt/appdatais 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.
Member discussion: