## 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

```bash
docker version --format '{{.Server.Version}}'
docker compose version
```

Example output:

```text
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

```bash
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

```bash
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:

```bash
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:

```bash
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:

```yaml
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:

```bash
echo "DB_PASSWORD=$(openssl rand -hex 16)" >> .env
```

Then reference it in Compose:

```yaml
environment:
  DB_PASSWORD: ${DB_PASSWORD}
```

Ensure `.env` is in `.gitignore`.

### Validate configuration

For Compose files:

```bash
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

```bash
docker pull myapp:2.0
```

Check the digest to ensure you have the correct image:

```bash
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.

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
docker compose up -d --no-deps myapp
```

The `--no-deps` flag avoids recreating dependent containers unnecessarily.

If standalone, stop, remove, and recreate:

```bash
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:

```bash
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:

```bash
docker compose ps
```

Check application logs:

```bash
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:

```bash
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:

```bash
curl -f http://localhost:8080/health
```

Expected output: `OK` or similar.

### Check container resource usage

```bash
docker stats --no-stream myapp
```

Compare CPU and memory usage with the previous version to detect regressions.

### Inspect logs for errors

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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.