Intro
Upgrading or migrating Docker containers is not just about pulling a newer image and restarting the container. Done wrong, it can cause data loss, configuration drift, silent compatibility failures, or extended downtime. Done right, it is a controlled sequence of observation, scoped change, verification, and recovery.
This guide walks through a practical upgrade and migration workflow for Docker containers, aimed at developers, DevOps engineers, and technical startup teams. We cover version inventory, safe configuration handling, pre-flight checks, migration steps with concrete commands, verification, rollback, and common pitfalls. Each stage includes executable commands, expected outputs, failure signals, and recovery actions so you can apply the process directly to your environment.
The core principle is operational safety: observe before changing, limit the blast radius, keep secrets out of the command line, verify at every step, and always have a tested rollback path.
Version and Environment Inventory
Start by understanding exactly what you are running. This is a read-only activity; do not change anything yet.
Check Docker and Compose versions
docker version --format '{{.Server.Version}}'
docker compose version
Example output:
24.0.7
Docker Compose version v2.21.0
If you are using an older Docker Engine or Compose file format, upgrading the container may require upgrading the host tools first. For example, Compose file version 3.x requires Docker Engine 1.13.0+; some newer syntax (like depends_on with conditions) requires Compose v2.20+.
List running containers and images
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
docker images
Note which containers are managed by Compose vs. standalone. For Compose projects, always use docker compose commands to ensure the desired state is applied consistently.
Inspect critical configuration
docker inspect <container> --format '{{json .Mounts}}' | jq
docker inspect <container> --format '{{json .Config.Env}}' | jq
docker inspect <container> --format '{{json .Config.Labels}}' | jq
Replace <container> with the actual name or ID. This shows volumes, bind mounts, environment variables, and labels. Pay special attention to:
- Volumes vs. bind mounts: named volumes (e.g.,
app_data:/var/lib/app) are managed by Docker and easier to backup/restore; bind mounts (e.g.,./data:/var/lib/app) map host paths and can cause portability or permission issues on other hosts. - Environment variables: do not expose secrets in the command. Use an env file or a secret manager.
- Healthcheck: if the image defines a healthcheck, note it; we will use it for verification later.
Know where data lives
Before any upgrade or migration, verify that your application data is persistent and not written into the container's writable layer. A quick test:
docker stop <container>
docker rm <container>
docker run -d --name <container> [same options] <image>
docker exec <container> ls /var/lib/app
If files appear, data is persistent. If the directory is empty, the application was writing to the container filesystem and will lose data on every recreate. Fix that by adding a volume before proceeding.
Safe Configuration Path
Treat configuration as code. Keep configuration in files under version control, inject it via environment variables or mounted config files, and never hardcode secrets.
Extract current configuration
For an existing container, export its configuration for review:
docker inspect <container> --format '{{json .Config}}' | jq > container-config.json
Review the JSON. Note healthcheck, entrypoint, command, environment, and volumes. For Compose services, the docker-compose.yml file is the source of truth; ensure it matches reality.
Prepare new configuration
Create or update configuration files in a safe directory. Example docker-compose.yml for a web app:
services:
web:
image: nginx:1.25
ports:
- "8080:80"
environment:
- NGINX_HOST=example.com
- NGINX_PORT=80
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- app_data:/usr/share/nginx/html
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
volumes:
app_data:
Notice the healthcheck; this will be used for verification.
Secrets handling
Never pass secrets via -e on the command line or commit them to files. Use Docker secrets (for Swarm), a .env file not under version control, or a secret manager. For example, with a .env file:
echo "DB_PASSWORD=$(openssl rand -hex 16)" >> .env
Then reference it in Compose:
environment:
DB_PASSWORD: ${DB_PASSWORD}
Ensure .env is in .gitignore.
Validate configuration
For Compose files:
docker compose config
This parses and validates the file, merges overrides, and shows the final configuration. Fix any warnings before proceeding.
Migration and Upgrade Steps
Now we can proceed with the actual upgrade or migration. We will assume a typical scenario: upgrading an application container from version 1.0 to 2.0, possibly with a configuration change, and moving from one host to another. The steps are similar for both.
1. Pull the new image
docker pull myapp:2.0
Check the digest to ensure you have the correct image:
docker images --digests
2. Test the new image in isolation
Run the new image on a different port or with a temporary container name to avoid disrupting the running service.
docker run -d --name myapp-test -p 8081:80 --env-file .env myapp:2.0
Point some test traffic at it or run smoke tests manually. Check logs:
docker logs myapp-test --tail 50
If the application has database migrations, run them against a copy of the database first.
3. Back up data and configuration
Before switching traffic, back up volumes and current config.
For a named volume:
docker run --rm -v app_data:/data -v $(pwd):/backup alpine tar czf /backup/app_data_backup_$(date +%Y%m%d_%H%M%S).tar.gz -C /data .
For bind mounts, simply cp -a the directory.
Also save the current container's config:
docker inspect myapp > myapp_current_config_$(date +%Y%m%d).json
4. Update the service
If using Compose, modify the image: tag in docker-compose.yml, then run:
docker compose up -d --no-deps myapp
The --no-deps flag avoids recreating dependent containers unnecessarily.
If standalone, stop, remove, and recreate:
docker stop myapp
docker rm myapp
docker run -d --name myapp [same options but new image] myapp:2.0
5. Verify the new version
Check that the container is running and healthy:
docker ps --filter name=myapp
Look for the health status column becoming healthy. If your image has no healthcheck, use the previously noted healthcheck or check logs and application endpoints manually.
For Compose:
docker compose ps
Check application logs:
docker logs myapp --tail 50
Watch for any new errors or warnings.
6. Migration scenario: moving to a new host
If you are migrating to a new host, after performing the backup, transfer the backup and configuration to the new host. Options include scp, rsync, or object storage.
On the new host, ensure Docker and Compose versions are compatible. Restore the volume data:
docker volume create app_data
docker run --rm -v app_data:/data -v $(pwd):/backup alpine sh -c "cd /data && tar xzf /backup/app_data_backup_*.tar.gz"
Then start the service with the updated configuration. Test thoroughly before decommissioning the old host.
Verification and Diagnostics
After the upgrade or migration, verify not just that the container is running, but that the application is functioning correctly.
Healthcheck endpoints
If your application exposes a health endpoint, use curl or a script:
curl -f http://localhost:8080/health
Expected output: OK or similar.
Check container resource usage
docker stats --no-stream myapp
Compare CPU and memory usage with the previous version to detect regressions.
Inspect logs for errors
docker logs myapp --since 30m | grep -i error
No output means no errors in the last 30 minutes.
Run automated smoke tests
If you have integration tests or scripts, run them against the new service. For example, a simple curl loop:
for i in {1..5}; do curl -s http://localhost:8080/api/items | jq .; sleep 2; done
Check that responses are as expected.
Compare configuration
Use docker inspect and diff against the saved JSON to ensure no unintended changes:
diff <(docker inspect myapp --format '{{json .Config.Env}}' | jq -S .) <(jq -S '.[0].Config.Env' myapp_current_config.json)
Adjust the jq expression based on the saved file structure.
Failure Modes and Recovery
Even with careful planning, failures happen. Here are common failure modes, how to detect them, and recovery actions.
Image pull fails
Symptom: docker pull returns authentication or network errors.
Recovery: Check registry credentials (docker login), network connectivity, DNS, and available disk space (df -h). If the image is not available, restore from backup or use the previous version.
Container fails to start
Symptom: docker run or docker compose up exits with a non-zero code.
Diagnosis: Check docker logs for application errors. Verify that required volumes or environment variables are present. Compare with the previous container's configuration.
Recovery: If the issue is configuration, fix and re-run. If the new image is broken, roll back to the previous image (see below).
Application starts but behaves incorrectly
Symptom: Healthcheck fails, endpoints return errors, or data is missing.
Diagnosis: Check logs, database connectivity, and volume mounts. If data is missing, verify that volumes are correctly attached and not empty.
Recovery: Roll back to the previous version, then investigate.
Data loss or corruption
Symptom: Empty directories, database errors, or missing files.
Prevention: Always back up before making changes.
Recovery: Restore from backup as soon as possible. To avoid future issues, use named volumes or bind mounts and test persistence before deployment.
Rollback procedure
Always have a rollback plan. For Compose:
docker compose down
git checkout <previous-commit> # if config is in version control
docker compose up -d
If using a previous image tag, change the tag back and re-run up.
For standalone containers:
docker stop myapp
docker rm myapp
docker run -d --name myapp [same options as original] myapp:1.0
Restore data from backup if necessary.
Common Pitfalls
Here are pitfalls that often cause failed upgrades or migrations, with why they happen and how to avoid them.
1. Skipping the backup
Why it happens: Overconfidence or time pressure. How to avoid: Make backup a mandatory step in your runbook. Automate it if possible.
2. Changing too many things at once
Why it happens: Combining image upgrade, config changes, and environment moves in one big step. How to avoid: Split changes into separate, independently verifiable steps. For example, first migrate with the same version, then upgrade the image.
3. Not testing data persistence
Why it happens: Assuming the application is configured to use volumes without checking. How to avoid: Perform the restart test described in the inventory section. Ensure data survives container recreation.
4. Hardcoding secrets in configuration or commands
Why it happens: Convenience or lack of awareness. How to avoid: Use env files, Docker secrets, or a secret manager. Never commit secrets to version control.
5. Ignoring healthcheck status
Why it happens: Relying only on container "running" state. How to avoid: Define a robust healthcheck for your application and monitor it during and after upgrade.
6. Not having a rollback plan
Why it happens: Optimism bias or lack of preparation. How to avoid: Write down the exact commands to roll back before starting the upgrade. Test the rollback in a staging environment.
Operations Checklist
Use this checklist before, during, and after any Docker container upgrade or migration. Assign each item a single owner (e.g., a specific engineer) and review the checklist at the beginning and end of the operation. For routine upgrades, review it monthly to ensure it remains current.
| Step | Action | Owner (example) | Verification |
|---|---|---|---|
| Pre-flight | Check Docker/Compose versions and prerequisites | Priya Shah, DevOps Lead | docker version, docker compose version output matches requirements |
| Inventory | List containers, images, mounts, and environment | Alex Chen, Ops Engineer | docker ps, docker inspect records saved |
| Config | Extract and review current configuration | Maria Gomez, App Developer | JSON/Compose file reviewed and backed up |
| Backup | Back up volumes and configuration | Alex Chen, Ops Engineer | Backup files exist and are readable |
| Test | Run new image in isolated container | Maria Gomez, App Developer | Smoke tests pass, logs clean |
| Change | Update service to new image with config changes | Priya Shah, DevOps Lead | docker compose up -d succeeds |
| Verify | Check container health, logs, endpoints | Alex Chen, Ops Engineer | Healthcheck healthy, endpoints return expected data |
| Rollback (if needed) | Execute documented rollback procedure | Priya Shah, DevOps Lead | Service restored to previous version, verified |
| Post-op review | Document what happened, lessons learned | All owners | Review meeting minutes, updated runbook |
Review frequency: For production services, review this checklist with the team monthly or before each major upgrade. For quarterly or ad-hoc migrations, review at the start of the migration project.
Conclusion
Upgrading and migrating Docker containers safely is a disciplined process that combines preparation, execution, and verification. By following the steps outlined here - inventorying your environment, managing configuration carefully, backing up data, testing before cutover, verifying after change, and having a rollback plan - you can avoid common pitfalls and keep your services reliable.
Remember: every command you run should have a known expected result and a recovery path if the result is different. Treat your container infrastructure as cattle, not pets, but keep the data and configuration as precious. With the right practices, upgrades and migrations become routine operations rather than risky events.
As a next step, pick one low-risk container in your environment and go through the inventory and backup steps. Then create a plan for upgrading it using the checklist. Build confidence in the process before tackling critical workloads.