Have a Rollback Plan Before You Deploy
09 Feb 2026“We’ll just roll back” is not a plan until you’ve written down what rollback actually means for your stack. Containers, database migrations, and static assets each roll back differently, and the one that bites people is always the database.
Containers: keep the last few images
If you’re deploying via Docker, tag images with the git SHA, not just latest:
docker build -t myapp:$(git rev-parse --short HEAD) .
docker tag myapp:$(git rev-parse --short HEAD) myapp:latest
Rollback is then “run the previous SHA’s image”:
docker service update --image myapp:a1b2c3d myapp-service
This only works if you kept the old image around. Set a retention policy — keep the last 10 tagged builds, prune older ones — so rollback always has somewhere to go, but your registry doesn’t grow unbounded.
Database migrations: this is the part that actually breaks
Rolling back application code is close to free. Rolling back a database migration that already ran against production data is not, and sometimes it’s not possible at all.
The rule: only ship migrations that are backward-compatible with the previous release. If you dropped a column in the migration that shipped with this release, and you roll back the app code, the old code still expects that column — it crashes. This is why expand-contract exists: the “contract” step (actually dropping the old column) should be its own deploy, shipped only after you’re confident you won’t roll back past the “migrate” step.
For a rollback plan, write down per migration:
| Migration | Rollback path |
|---|---|
| Add nullable column | Safe to roll back app code; column stays, harmless |
| Backfill data | Safe; backfilled data doesn’t hurt old code |
| Drop column | Not safely reversible — only ship after migrate step is proven stable |
| Add NOT NULL constraint | Roll back app code first if constraint could reject old code’s writes |
If a migration falls in the “not safely reversible” row, that’s your signal to wait longer before shipping it, not to skip planning for it.
Static assets: version by content hash
If your build outputs main.a3f9c2.js (hashed filename) rather than main.js, old and new assets can coexist in your CDN or object storage indefinitely. Rolling back the app to serve old HTML that references the old hashed filename still works, because that file is still there — nobody overwrote it.
// webpack/vite config
output: {
filename: '[name].[contenthash].js',
}
If your build overwrites main.js in place on every deploy, rollback breaks the moment a user has an old HTML page cached (referencing a main.js that’s now different content) — they get a version mismatch, which shows up as broken pages or console errors with no obvious cause.
The actual rollback runbook
Write this down before you need it, not during an incident:
- Identify the last known-good SHA. Tag releases, don’t rely on remembering.
- Roll back the container/app code first. This is the fast, low-risk step.
- Check if any migration since the last-good SHA falls in the “not reversible” category. If yes, rolling back app code alone isn’t enough — you may need a forward-fix instead of a rollback.
- Roll back, verify, then decide on the database separately. Don’t reflexively run a
downmigration under pressure; check the table above first.
As a decision, it looks like this:
flowchart TD
A["Roll back app code\nto last known-good SHA"] --> B{"Any migration since then\nnot safely reversible?"}
B -->|"No"| C["Done - code and db\nboth rolled back cleanly"]
B -->|"Yes"| D["Rolling back app code alone\nisn't enough"]
D --> E["Forward-fix instead\nof rolling back the db"]
Takeaway
Rollback is fast and safe for code and assets if you’ve prepared for it (SHA-tagged images, content-hashed assets). It is slow and sometimes impossible for databases, which is why the actual discipline is making sure every migration you ship is backward-compatible with the release before it — rollback plan starts at migration-writing time, not at incident time.