A rollback with Docker image tags means redeploying the exact image that worked before, not rebuilding from older source. If you tag every release and pin that tag in your Compose file, recovery is one edit and one docker compose up -d away.
This guide assumes you already build and deploy images to a registry. If you do not, start with Build and push Docker images with GitHub Actions first.
Before you start
You need:
- A Linux VPS with Docker and the Compose plugin installed. See Install Docker and Docker Compose on a Linux VPS if you are starting fresh.
- SSH access to the server, and a user in the
dockergroup (orsudo). - A registry you can push to — Docker Hub, GHCR, or a private registry.
- A
docker-compose.ymlthat references an image by tag, not bylatest. - The current running version noted somewhere, so you know what to go back to.
Tip: Never deploy latest to production. A floating tag gives you no target to roll back to, because the tag itself has already moved.
Tag releases so you always have a target
A useful release has two tags: a human version and the Git commit SHA. The version is easy to read; the SHA is unique and never reused.
Build and tag locally, then push both tags:
export VERSION=1.4.2
export SHA=$(git rev-parse --short HEAD)
docker build -t registry.example.com/myapp:${VERSION} -t registry.example.com/myapp:${SHA} .
docker push registry.example.com/myapp:${VERSION}
docker push registry.example.com/myapp:${SHA}If your CI does the build, make it push the same pair. The GitHub Actions build guide shows how to derive the tag from the Git ref.
Check what is actually on the host before you rely on it:
docker image ls registry.example.com/myappYou should see the new version and the SHA side by side. If the previous version is missing from the registry, you cannot roll back to it — keep old tags around for at least a few releases.
Pin the tag in docker-compose.yml
Open your Compose file and replace any floating tag with the version you just pushed.
services:
app:
image: registry.example.com/myapp:1.4.2
restart: unless-stopped
env_file:
- .env
ports:
- "127.0.0.1:8080:8080"Commit that change. The Compose file is now the record of what is deployed, which is what makes the rollback auditable later.
Note: If you use override files for environments, keep the image tag in the base file so production and staging cannot drift. See Docker Compose override files.
Deploy the pinned version:
docker compose pull app
docker compose up -d app
docker compose pspull fetches the exact tag, up -d recreates only the container whose image changed, and ps shows the new container as running.
Roll back to the previous tag
When the new version misbehaves, the fix is to point the Compose file at the tag that worked.
Warning: Recreating a container stops the running process. If your app writes to a local volume, make sure writes are flushed and, for anything with a database, take a backup first — see Back up your Linux VPS with rsync, restic and cron.
- Note the bad version so you can investigate later:
docker compose images app- Edit
docker-compose.ymland set the image back to the last good tag:
image: registry.example.com/myapp:1.4.1- Pull and recreate:
docker compose pull app
docker compose up -d app- Confirm the container is running the old image:
docker inspect --format '{{.Config.Image}}' $(docker compose ps -q app)The output should read registry.example.com/myapp:1.4.1.
If the image is already on the host, pull is a no-op and the switch takes a second. If it is not, Docker fetches it from the registry first.
Tip: For a faster emergency switch, keep the previous image locally with docker image tag registry.example.com/myapp:1.4.1 myapp:previous and reference myapp:previous in a temporary override file.
Handle database migrations before you roll back
Code rolls back cleanly. Schema changes often do not. If the bad release ran a migration that dropped or renamed a column, the old image may fail against the new schema.
Check what the release changed:
docker compose logs --tail=200 app | grep -i migratIf the migration is not reversible, you have two options: restore the database from the backup taken before the deploy, or roll forward with a hotfix. Restoring is the safer path when the data loss is small and recent. For MariaDB, mariabackup full and incremental restores covers the procedure.
Warning: Restoring a database overwrites current data. Confirm the backup timestamp and take a fresh dump of the current state before you restore anything.
Check it works
Verify from the server and from outside it.
docker compose ps
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health
docker compose logs --tail=50 appA 200 from the health endpoint and no new errors in the logs means the rollback took. Then load the app through your reverse proxy or public URL and run the specific action that broke — a login, a checkout, an upload — rather than trusting the health check alone.
Finally, confirm the version the app reports matches the tag you pinned:
docker compose exec app printenv APP_VERSIONIf your image does not expose a version variable, add one at build time. It saves guesswork during incidents.
Troubleshooting
manifest unknownon pull — the tag does not exist in the registry. Check the spelling, and confirm the CI job that was supposed to push it actually succeeded. Tags are case-sensitive.- Container restarts in a loop after rollback — the old image is running against a newer schema or a changed
.env. Compare the environment variables the release added with your current.env, and see Manage secrets and environment variables in Docker Compose. up -dsays the container is up to date — Compose matched the container to the image ID and skipped the recreate. Force it withdocker compose up -d --force-recreate app.- Old tag was deleted from the registry — you cannot pull it. Rebuild from the tagged commit in Git, or restore the image from a host that still has it with
docker saveanddocker load. - Rollback worked but the site is still broken — the problem is outside the container: proxy config, DNS, or a dependency such as Redis or the database. Check container networking in bridge vs host mode.
Next steps
- Automate the build-and-tag step so every release gets a version and a SHA: Build and push Docker images with GitHub Actions.
- Wire the deploy into CI so the Compose file is updated for you: Deploy from GitHub Actions to your VPS over SSH.
- Get startup order and health checks right so a bad container fails fast instead of half-starting: Docker Compose depends_on and healthchecks.
