IAnswerable

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:

  1. Identify the last known-good SHA. Tag releases, don’t rely on remembering.
  2. Roll back the container/app code first. This is the fast, low-risk step.
  3. 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.
  4. Roll back, verify, then decide on the database separately. Don’t reflexively run a down migration 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.