Skip to main content
BlogDevOps

How to Roll Back a Docker Deploy on a VPS (Beginner Guide)

Beginner guide to rolling back a Docker deploy on a VPS: unique image tags, record the old version, deploy with --wait, restore the tag, verify, and the limits.

Matthews Wong

Software Engineer

October 202610 min read
How to Roll Back a Docker Deploy on a VPS (Beginner Guide)

Key Takeaway

To roll back a Docker deploy on a VPS: give every image a unique tag such as the commit SHA, record the tag that is running, and when a release breaks, set IMAGE in .env back to the previous tag and run docker compose up -d --wait. Verify with docker compose ps and curl. A rollback restores code, not data or database migrations.

You just deployed a new version and the home page shows an error. Visitors have already seen it, and in the terminal you type whatever comes to mind: git reset, rebuild, docker compose down. Most of the time is lost not because a rollback is hard, but because nobody knows exactly which version used to work, or that version has been overwritten by the latest tag.

This guide is the rollback steps only, not a guide to detecting problems: you set the system up before a release (unique tags, a record of versions), then use it when a release breaks. The examples follow the same pattern as the setup behind this site, rewritten with placeholders, and assume you already have a production Compose file like the one in the guide to deploying Docker Compose in production. Every flag is checked against the official documentation listed at the end.

Want the concepts first?

This post is the hands-on rollback walkthrough. Our free lesson explains the bigger picture, from health checks to backups, with an interactive diagram of a bad release being rolled back: Monitoring, Rollback and Backups for a Docker Server. The lesson also has a short quiz and a rollback drill you can run on your laptop.

What you need

Get these four things ready before the next release, not after something breaks. Every name below is a placeholder, so replace it with your own.

  • A Linux VPS with Docker Engine and the Compose plugin, and SSH access as a normal user (named deploy in the examples).
  • An app run through docker-compose.yml in /home/deploy/my-app, with the image tag read from an IMAGE variable in the .env file.
  • Images pushed to a registry (ghcr.io/example-user/my-app in the examples), so an old version can always be pulled again.
  • One health check in the Compose file. Without a health check, --wait has no yardstick for calling a release failed.

Step 1: Give every image a unique tag, not just latest

A rollback means running the previous known-good version again. That is only quick when the version has a name that points at exactly one build. The latest tag moves with every push, so after a bad release latest points at the bad build. Use the commit SHA as the tag: it is never reused, so it always means the same build.

# Wrong: one moving name. The next push overwrites the build you want back.
docker build -t ghcr.io/example-user/my-app:latest .

# Right: one tag = one build. Here the short commit hash is the tag.
TAG=$(git rev-parse --short HEAD)
docker build -t ghcr.io/example-user/my-app:$TAG .
docker push ghcr.io/example-user/my-app:$TAG

# docker-compose.yml: the tag comes from IMAGE in .env, never hard-coded
#   image: ${IMAGE:-ghcr.io/example-user/my-app:latest}

This site's pipeline pushes every image twice, as latest and as the full commit SHA, and the deploy step writes that SHA tag into the server's .env file as IMAGE. So the live version is always one exact build. The example above uses a short SHA to keep it typeable; in CI, let the pipeline tag the image.

Expected result: In docker image ls and in the registry, every build has its own tag, and an old tag does not change when a new build arrives.

Step 2: Record the running version before you deploy

In a panic, the tag that was running well is the first thing to slip from memory. Write it down before changing anything. One line in a text file is enough, and do not copy the whole .env because it contains secrets.

cd /home/deploy/my-app

# Show the tag running right now and keep a copy outside .env.
# Only the IMAGE line is saved: .env also holds secrets, so never copy all of it.
grep '^IMAGE=' .env | tee -a deploy-history.txt

Expected result: One line shaped like IMAGE=ghcr.io/example-user/my-app followed by a tag appears in the terminal and is saved in deploy-history.txt. That line is your rollback destination.

Step 3: Check that the previous image is still on the server

A rollback is fastest when it starts from an image already on disk. See what is available. The default columns of docker image ls are REPOSITORY, TAG, IMAGE ID, CREATED and SIZE, and --format with a Go template gives a more compact view.

# Every image of this app on the server
docker image ls ghcr.io/example-user/my-app

# A compact view: tag, age, size
docker image ls ghcr.io/example-user/my-app --format 'table {{.Tag}}\t{{.CreatedSince}}\t{{.Size}}'

# Wrong: removes EVERY image no container uses, including the one you may need tonight
# docker image prune -a
# Right: only images older than 72 hours
docker image prune -af --filter "until=72h"

If the old image is gone, that is not necessarily a problem as long as it is still in the registry: docker compose pull fetches it again, possibly after a docker login. This site's cleanup is a deliberate compromise: images created in the last 72 hours are kept, the portfolio's own images are capped at the newest two (the running release and one for a rollback), and older releases remain in the registry. That cleanup never removes volumes.

Expected result: A table with several tags of your app's image, including the tag from Step 2. If that tag is missing, note that you will need to pull during the rollback.

Step 4: Deploy the new version with --wait

A normal deploy uses the same pattern as a rollback: change IMAGE, pull the image, run again. The difference is that we use --wait. Docker's documentation describes it as waiting for services to be running or healthy, and --wait-timeout sets a limit in seconds. Without it, up -d returns once the container is started, without waiting for the health check result.

cd /home/deploy/my-app

# Point IMAGE at the NEW tag (the old one is already in deploy-history.txt)
sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/example-user/my-app:7b1e9d4|' .env

docker compose pull
# --wait: wait for running + healthy; give up after 120 seconds
docker compose up -d --wait --wait-timeout 120
echo "exit=$?"

In the lesson's drill, a release whose /health answered 500 made --wait exit 1 after about 19 seconds, with health checks 5 seconds apart. Docker marks a container unhealthy after retries consecutive failures. Note also that visitors were still served by that broken release, because unhealthy is only a label.

Expected result: If the release is healthy, exit=0 is printed. If it is unhealthy or times out, the exit code is not zero, and that is your signal to go to Step 5.

--wait only makes the deploy notice the failure, it does not undo anything. This site's own deploy runs docker compose up -d without --wait, so a release that fails its health check still ends the workflow green. The rollback is still a step you have to take.

Step 5: Roll back by restoring the old tag

This is the whole rollback: write the old tag from Step 2 back into IMAGE, check that Compose reads it, then run up again. Because the old tag points at one exact build, nothing has to be rebuilt. Compose recreates the container because the image differs from the running one.

cd /home/deploy/my-app

# 1. Put the OLD tag back (the one from deploy-history.txt)
sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/example-user/my-app:3f9c2a1|' .env

# 2. Check that Compose really reads the old tag before touching anything
docker compose config | grep 'image:'

# 3. Make sure the image is on the server, then recreate with the old one
docker compose pull
docker compose up -d --wait --wait-timeout 120
echo "exit=$?"

# Wrong: this deletes containers AND, with -v, the named volumes (your data)
# docker compose down -v
# Wrong: latest already points at the broken build, so nothing goes back
# sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/example-user/my-app:latest|' .env

docker compose config prints the interpolated configuration, so the image: line shows the tag that will actually be used. This small check prevents a rollback that silently changes nothing, for example because the sed pattern did not match. Do not use down -v for a rollback: the -v option removes the named volumes declared in the Compose file, and that means data.

Expected result: docker compose config shows the old tag, then up -d --wait finishes with exit=0 once the old container is healthy. In the lesson's drill this took about 8 seconds.

Step 6: Verify from the inside and from the outside

Do not stop at exit=0. The health check runs inside the container, so it cannot see DNS, TLS or the reverse proxy in front of it. Check from two sides: on the server itself, and through the public domain the way visitors arrive.

# 1. Container state: running, and healthy once the start period has passed
docker compose ps

# 2. The app answers on the server itself
curl -I http://127.0.0.1:3002

# 3. The app answers the way visitors reach it (through DNS, TLS and the proxy)
curl -I https://example.com

# 4. The app's own log: did it boot the old version without errors?
docker compose logs --tail 50 web

If the log shows errors, read it the way the guide to viewing Docker logs for beginners describes. A successful rollback means the old app boots with no new errors.

Expected result: The container is running, and healthy once the start period has passed. Both curl calls answer with status 200 or a redirect you expect, and the log shows the app is ready.

Step 7: Make the rollback permanent through git or the pipeline

Editing .env on the server is only a stopgap. This site's pipeline rewrites .env with the IMAGE of the newest build on every deploy, so the next deploy would bring that broken code back unless the code has been fixed. There are two proper ways: git revert the bad commit and push, or re-run the workflow from the last successful run.

# Option A: undo the bad commit and push it through the normal pipeline
git log --oneline -5
git revert 7b1e9d4
git push origin main

# Option B (GitHub web page): Actions > the last GOOD run
#   > Re-run jobs > Re-run all jobs
#   Same commit SHA as the original run, allowed up to 30 days later.

GitHub's documentation says a re-run uses the same commit SHA as the original run and is allowed up to 30 days after the first run. git revert is slower but leaves a clear trail in the history. The full pipeline is covered in the guide to deploying Next.js to a VPS with Docker and GitHub Actions.

Expected result: The new workflow ends green with a healthy IMAGE, and docker compose ps on the server shows the tag you expect. After that, fix the bug calmly and release a new version.

What a rollback cannot undo

A rollback only swaps code. Database migrations that have already run stay applied, and data written by the new version stays. If the new version dropped or renamed a column, the old version that comes back can fail on the changed schema. In that case the rollback succeeds and the app is still broken.

The prevention is in how you write migrations: change the schema in two phases so both the old and new versions can run, as in the guide to zero-downtime migrations and rolling deploys. For data, treat a rollback as no substitute for a backup. Take a backup before any release that touches the database, and test the restore, as in the 3-2-1 backup strategy for a VPS.

Do not roll back by deleting volumes or running docker compose down -v. That does not bring the old code back, it only erases the app's and the database's data.

Common errors and how to fix them

These are what most often go wrong the first time you roll back. Match your symptom to the first column, then read the cause and the fix.

SymptomCauseFix
You ran the rollback but the app is still brokenIMAGE did not change (the sed pattern did not match, or you are in the wrong folder), or you used latest, which already points at the broken build.Run grep IMAGE .env and docker compose config, make sure the old tag shows up, and use the SHA tag, not latest.
Compose fails to pull the image during the rollbackThe tag is mistyped, you are not logged in to the registry, or the image has been deleted from the registry.Compare the tag with deploy-history.txt, run docker login for the registry, then repeat docker compose pull.
The old image is not on diskCleanup such as docker image prune -a with no filter removed every image no container uses.Pull it again with docker compose pull. Going forward, use an age filter such as until=72h and never prune volumes.
After the rollback, up -d --wait still exits with a non-zero codeThe old version is unhealthy too: a migration changed the database schema, or the configuration in .env has changed.Read docker compose logs --tail 50 web. If the schema is the cause, a rollback alone is not enough; restore from a backup or ship a fix forward.
The next deploy brings the broken code backThe pipeline rewrites IMAGE in .env on every deploy, and the broken commit is still on the branch.Run git revert and push, or fix the bug before deploying again.
App data is gone after the rollbackThe rollback was done with docker compose down -v, which removes the named volumes in the Compose file.Restore from the latest backup. For a rollback, just change IMAGE and run up -d --wait, without down -v.
The container is healthy but visitors still see an errorThe health check only looks from inside the container, so problems in DNS, TLS or the reverse proxy are invisible to it.Run curl against the public domain, not only 127.0.0.1, and check the proxy configuration.

Checklist before the next release

  • Every image is pushed with a commit SHA tag, and the server reads that tag from IMAGE in .env.
  • The running tag is recorded in deploy-history.txt before IMAGE is changed.
  • The previous version's image is still on disk, or certainly in the registry and you are logged in.
  • The deploy uses docker compose up -d --wait with --wait-timeout, and the exit code is watched.
  • After a rollback you verify with docker compose ps, a local curl, a curl to the public domain, and the log.
  • A release that touches the database has a tested backup, and its migration can run alongside the old version.

Learn more

This guide answers one question: how to bring back a broken release. Our free lesson puts it in a bigger picture, including a bad-release drill you can run on your laptop and a list of what needs a backup on one server.

Open the lesson: Monitoring, Rollback and Backups

Practise this rollback once on your laptop before you need it on a server that is on fire. A rollback you have tried is far calmer than one you run for the first time while visitors are looking at an error.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why does my Docker rollback not change anything?

Most often IMAGE in .env did not actually change, because the sed pattern did not match or you ran the command in the wrong folder. Run docker compose config and look at the image: line before running up. If you point IMAGE at latest, it already refers to the broken build, so use a commit SHA tag.

Does docker compose up --wait roll back a failed release?

No. --wait waits for services to be running or healthy and exits with a non-zero code on failure, but it never undoes the deploy. The broken release can keep serving visitors, so the rollback is a separate step you or a script must run.

Can I roll back a Docker deploy if I tagged my image latest?

Not reliably, because latest moves with every push and after a bad release it points at the bad build. If an older image still exists on the server you can find it by IMAGE ID, but a unique tag such as the commit SHA is the safe habit. Start tagging every build now.

Does a rollback also undo my database migration?

No. A rollback only swaps the code running in the container, and migrations that already ran stay applied. Write migrations in two phases so the old version still works, and take a tested backup before a release that touches the database.

Is it safe to use docker compose down -v to roll back?

No. The -v option removes the named volumes declared in the Compose file, which usually hold your data. To roll back, change IMAGE to the previous tag and run docker compose up -d --wait, and leave the volumes alone.