Skip to content

Liber Tertius · Chapter 4 · Shipping

Roll back a deploy

Going back is one command and needs no tag, which is why it is worth knowing exactly what it leaves alone. It swaps the image. It does not reverse migrations, and it keeps the built assets of the release you are leaving.

≈5 min 5 steps A project with at least two deploys
Source

Before you start

A release went out and the site is worse for it. The project was deployed at least twice, so there is a tag to return to. The first deploy has nothing behind it, as First deploy to a fresh VPS says.

You are in the project directory, and your SSH key is accepted for the deploy user on the server.

$ ssh deploy@203.0.113.10 true

Decide one thing before you start: did the bad release bring a migration. A rollback leaves the database as the new release made it. A migration that only added a table or a nullable column does no harm to the old code. One that dropped or renamed something does, and then step two comes first.

The ritual, in order

Five steps. The third is the rollback itself. The second and the fifth are optional, and the second has to come before the third when you need it at all.

  1. Read the history

    Every successful deploy and every rollback appends a line to .deploy-history in the project's directory on the server. grim remote:exec reads it without an SSH session of your own.

    $ grim remote:exec cat .deploy-history
    2026-03-02T10:14:07+00:00 v1.4.1
    2026-03-09T16:40:52+00:00 v1.4.2
    2026-03-10T09:03:31+00:00 v1.4.3

    The last line is what runs now. The line above it is where the rollback will take you. grim remote:exec takes no environment and reaches the one registered first, which is production in the usual case. For staging, run ssh deploy@203.0.113.10 cat /opt/shop-staging/.deploy-history.

  2. Reverse the migration while the new code is still there

    Optional. Do it only when the old code cannot live with the new schema. The down() methods are in the new image, so after the rollback there is nothing left that could run them. Ask first which batch is the last one, with grim remote:artisan.

    $ grim remote:artisan migrate:status
    $ grim remote:artisan migrate:rollback --force

    migrate:rollback drops whatever the last batch created, with the rows customers wrote there since the release. There is no confirmation and no undo. When in doubt, leave the schema alone and ship a fix forward instead.

  3. Roll back

    grim deploy with --rollback takes the tag before the current one from the history, pulls it and starts the containers on it. It asks no question and runs no hooks.

    $ grim deploy production --rollback
    Rolling back to previous version...
    Rolling back to v1.4.2
    
    Rollback complete: v1.4.2

    The rollback writes itself into the history as v1.4.2 (rollback). That has a consequence. Run the command a second time and the tag before the current one is v1.4.3, the release you were escaping from. Two rollbacks bring you back.

  4. Check the site

    The rollback waits for nothing and verifies nothing, so look yourself. Ask the healthcheck and read what the app logged while it started, with grim logs.

    $ curl -I https://shop.example.com/api/v1/healthcheck
    $ grim logs app --remote=production

    Look at the pages in a browser too. The volume that holds public/ is replaced only by a full deploy, so the site still serves the CSS and JavaScript built for v1.4.3. When the fault was in the PHP code, that is fine. When it was in the front end, go on to step five.

  5. Deploy an older tag by name

    Optional. This is the way to go further back than one step, and the way to get everything of an older release, assets included. It is an ordinary deploy of a tag that still sits in GHCR.

    $ grim deploy production --tag=v1.4.1
    Deploy shop@v1.4.1 to 203.0.113.10 (production)?

    It asks, replaces the public/ volume, waits for the app to turn healthy and runs the hooks. php artisan migrate --force finds nothing to do, because the older image carries no migration the database has not seen. ✓ Deployed shop@v1.4.1 to 203.0.113.10 is the last line, and the history gets a plain v1.4.1 entry.

What you have now

Production runs the older image. On the server, .deploy-history tells the story in order, and its last line is the truth about what runs.

2026-03-10T09:03:31+00:00 v1.4.3
2026-03-10T09:21:12+00:00 v1.4.2 (rollback)

The bad image is still in GHCR under its tag. Nothing deletes it and nothing will deploy it again by itself. The next grim release takes the next patch number, v1.4.4, and that is where the fix goes. The database is as the newest migration left it, unless you ran step two.

When it does not work

“No previous version found in .deploy-history”

There is no history file in that directory. Either the project never finished a deploy, or the path is wrong. For staging the directory is /opt/shop-staging, and the environment goes on the command line: grim deploy staging --rollback.

The rollback ran and nothing changed

The history has one line. The tag before the current one is then the current one, and the server was restarted on the image it already had. There is nothing older on this server. Deploy a tag by name as in step five, or fix forward.

The deploy rolled itself back

A deploy that fails its last check prints Rolling back to v1.4.2 by itself and ends with an error. That covers one failure only: the app started and php artisan about kept failing. The server is back on the last good tag and the history is unchanged. Read the log, fix the cause and release again.

The app container never turned healthy and nothing rolled back

App container failed to become healthy stops the deploy, prints the last lines of the app log and leaves the new containers as they are. The automatic rollback does not cover this case. Run step three yourself.

“Image not found. Build it first:”

The tag you named in step five is not in GHCR under the image name grim.json gives now. Check the spelling against the history. When docker.owner changed since that release, the old tags live under the old name.

“Rollback failed.”

The pull or the restart failed on the server, and the lines above the message say which. unauthorized means the server's GHCR login no longer works. Give it a fresh token with grim server:ghcr-login --host=vps1 and roll back again.