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.txtExpected 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|' .envdocker 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 webIf 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.
| Symptom | Cause | Fix |
|---|---|---|
| You ran the rollback but the app is still broken | IMAGE 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 rollback | The 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 disk | Cleanup 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 code | The 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 back | The 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 rollback | The 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 error | The 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.

