Docker Compose for portable homelab deployments

Vendor lock-in creeps in quietly: a pinned cloud dashboard, a backup tool in a proprietary format, a reverse proxy config hardcoded to an IP nobody wrote down. By the time you need to migrate, the compose file is the only thing you trust, and even that can lie to you.

How to escape vendor lock-in in your homelab: Docker Compose, snapshots, VLANs, DNS and backup patterns

Vendor lock-in in a homelab is rarely dramatic. It creeps in as a pinned cloud dashboard, a backup tool that only speaks one proprietary format, or a reverse proxy config that hardcodes a host IP nobody wrote down. By the time you try to migrate a service, the compose file is the only thing you trust, and even that can lie to you.

This covers the practical patterns for keeping your self-hosted stack portable: what to version, what to snapshot, what to back up, and the network assumptions that silently break a migration that compose files alone cannot protect against.

The compose file is your recovery contract

Pin every image tag explicitly. Running latest means a restore to a fresh host can pull a different image version than the one your data was written against. A Nextcloud latest that upgrades a major version on first boot against an older database schema will corrupt the install. Use digest-pinned or version-specific tags: nextcloud:28.0.3-apache, not nextcloud:latest. Commit that tag to Git and bump it deliberately.

Named volumes are the other half of the contract, and the part most people miss. When you run docker rm or docker compose down, bind mounts survive because they are directories on the host. Named volumes do not get cleaned up by default, but docker compose down -v removes them without a warning prompt. The distinction matters at 2am when you are recovering a service and cannot remember which flag you used. Map every stateful path explicitly in the compose file so you know exactly what needs backing up.

Store every compose file in a Git repository, one directory per service. The docker-compose.yml, the .env file (secrets excluded, replaced with variable names and documented), and any Caddyfile or nginx config that belongs to that service. That repository is the single source of truth. If the host dies and you cannot recall how a service was configured, the repository tells you. If you have to rebuild on a second host to test a restore, the repository is what you clone.

VM snapshots as a safety net for compose-only gaps

A compose file describes what to run. It does not capture the state of a running database mid-transaction, the contents of a named volume, or custom kernel parameters set on the host. VM snapshots on Proxmox fill that gap. Take a snapshot before any update that touches the database layer or the storage driver. A daily automated snapshot via the Proxmox backup schedule with a retention of three is enough for most homelab workloads; it is not a substitute for off-host backup, but it gets you back to a known-good state in under two minutes without a full restore cycle.

Backing up named volumes with offen/docker-volume-backup

offen/docker-volume-backup runs as a companion container in the same compose stack. It mounts your named volumes under /backup/ paths and ships compressed archives to a local directory, S3-compatible storage, WebDAV, SSH, Dropbox, or Google Drive on a cron schedule. The image is under 15MB. Configure it with environment variables: BACKUP_CRON_EXPRESSION sets the schedule, BACKUP_RETENTION_DAYS controls rotation, and BACKUP_STOP_CONTAINER_LABEL tells it which containers to stop before backing up, which matters for transactional databases. Add it to the compose file alongside the service it protects, commit it to Git, and the backup schedule migrates with the service.

A minimal addition to an existing stack looks like this:

yaml
backup:
image: offen/docker-volume-backup:v2
environment:
BACKUPCRONEXPRESSION: “0 2 * * *”
BACKUPRETENTIONDAYS: “7”
BACKUPFILENAME: “backup-%Y-%m-%dT%H-%M-%S.tar.gz”
volumes:
– myapp
data:/backup/myapp_data:ro
– /var/backups/myapp:/archive
restart: unless-stopped

Test the restore before you need it

Spin up a second host, clone the repository, substitute the volume archive, and bring the stack up. Do this before you need it. The most common failure mode is discovering that the backup ran but the archive was written to the wrong path, or that the .env file was never committed and the secrets are lost. A restore test surfaces both within ten minutes and costs nothing except the time to confirm it works.

Where network assumptions break a migration

Split-horizon DNS

Split-horizon DNS means your internal resolver returns a private IP for app.yourdomain.com while the public DNS returns your external IP or nothing at all. Services that communicate with each other using internal hostnames depend entirely on that resolver being present and returning the right answer. Move a stack to a new host on a different subnet, or restore it to a cloud VPS, and those internal names resolve to nothing or to the public IP with hairpin routing disabled at the firewall. Document which services use internal hostnames for inter-service calls. Where possible, use Docker network aliases and container names for container-to-container traffic instead of DNS names that depend on the host resolver.

VLAN segmentation and inter-VLAN firewall rules

Containers inherit the network of the host they run on. A container on a host in VLAN 20 that calls a database on a host in VLAN 10 depends on an inter-VLAN routing rule allowing that traffic. Move the application container to a host in VLAN 30 and the database call fails silently or with a generic connection timeout. Write down the VLAN each service lives in and the inter-VLAN ACL that permits its traffic. Keep that documentation next to the compose file in the same Git repository. When you migrate, update the firewall rules first, then bring the stack up.

nftables and UFW after a host migration

Both nftables and UFW rules are host-specific. A container that worked on the old host because ufw allow from 192.168.20.0/24 to any port 5432 was in place will fail on the new host where no such rule exists. The failure looks like a database connection timeout, not a firewall rejection, which costs time to diagnose. Before migrating any service, run ufw status numbered or nft list ruleset on the source host and copy the relevant rules to the target. For nftables, export the full ruleset with nft -j list ruleset > ruleset.json and diff it against the target host’s ruleset before cutting over.

Reverse proxy ACLs bound to host IPs

A Traefik or nginx reverse proxy config that specifies a Host IP in a server_name directive, or a middleware that whitelists 192.168.1.50 as an allowed source, breaks the moment the service moves to 192.168.1.51. Use container network names and Docker labels for Traefik routing wherever possible. For nginx, bind to the service name in the upstream block rather than an IP. Any IP-bound ACL should be in a separate file that is clearly marked as host-specific, not buried inside the service config that gets committed to Git as if it were portable.

Telemetry endpoints and subscription checks behind egress rules

Some self-hosted tools phone home on startup to validate a licence key or send telemetry. Authentik, Portainer, and several monitoring tools do this. A restrictive egress rule on a new host’s firewall will cause the container to hang at startup, time out on a check call, or log a non-fatal error that is easy to miss. Audit egress rules on the target host before migrating. Check the application’s documentation for any DISABLE_TELEMETRY, TELEMETRY_ENABLED=false, or licence-check endpoint that needs outbound access, and decide whether to allow it or disable it at the application layer.

Auditing subscription creep before you migrate

A migration is the best time to find services you are paying for but no longer need. Portainer Business, a Tailscale paid plan, a cloud backup target you set up once and forgot, a Cloudflare Access subscription that predates your self-hosted auth proxy. List every external service your homelab touches: DNS providers, tunnel providers, monitoring SaaS, off-site backup storage. Cross-reference against your bank statements. Services that appear in compose files as environment variables pointing to third-party endpoints are a useful starting point. If a service has a BACKUP_S3_ENDPOINT, CLOUDFLARE_API_TOKEN, or SMTP_HOST pointing somewhere external, that is a dependency that has a cost and a migration requirement. Resolve those before the migration, not after you have already decommissioned the old host.