Skip to content
2BKK

Blog · Operations

Blue/green deploys on one server, no 502s

By 2BKK · Published 27 September 2026

For a long time, every deploy of one of our busier sites was docker compose down followed by docker compose up -d. The build ran first, so the gap was short, but it was real: a window of 502s on every deploy, on a site where people pay at any hour. The database container restarted with the app, which it had no reason to do.

The same server now deploys with no gap. No Kubernetes, no second machine. Here is the whole setup, and the parts that surprised us.

The shape

  • Two app services in the compose file, app-blue and app-green, identical except for the name. Only one runs outside a deploy.
  • A tiny nginx container, front, that owns the host port the outside web server proxies to. Its config has one upstream: the active colour.
  • A deploy script that builds an image tagged with the Git commit, starts the idle colour with that image, waits for its health check, points front at the new colour, reloads nginx, and stops the old colour.
  • Rollback is the same script with a rollback argument. It points front back at the previous colour, which is still built and starts in seconds.

The app adds a response header naming the colour that served the request, so anyone can confirm which build is live with curl -I.

The script, step by step

  1. git fetch and git reset --hard origin/main on the server. Unpushed commits in that checkout are lost, so nobody keeps work there.
  2. docker build -t app:<sha> . Nothing is running from this image yet, so a broken build changes nothing.
  3. Read the active colour from a state file. Start the other colour with the new image. The service has a health check that requests a page and expects a 200.
  4. Wait for the health check. On timeout, stop the new colour, keep the old one, exit non-zero. The deploy job fails loudly; the site never noticed.
  5. Write the new upstream into the front config and nginx -s reload. Reload keeps open connections and routes new ones to the new colour.
  6. Stop the old colour. Update the state file. Log DONE active: <colour>.
  7. Delete every app image except the three newest tags.

Step seven was added after the disk filled up. Months of deploy images that nothing removed had taken most of it; pruning them freed more than half the disk.

What triggers it

A GitHub Actions job on push to main connects over SSH and runs the script. The job fails if the script fails, so a broken build shows up in the pull request, not on the site. A manual trigger exists for content-only deploys.

What surprised us

  • The database does not belong in any of this. It is a separate service the script never touches. It only restarted before because compose down took everything down.
  • A health check on the port alone is not enough. A build that starts but cannot render a page must not go live, so the check requests a page.
  • compose ps stops being useful once two services share a role. docker ps filtered by image name tells the truth.
  • Anything baked into the client bundle at build time has to be present on the build machine. Building somewhere else and copying the image produced a site with the wrong public configuration. Every build now runs through the same pipeline.

When this is enough

One server, one app, a few deploys a day, and a business that cannot afford a 502 during checkout. That describes most of the sites we work on. If you need more than one server, use a real orchestrator. Until then, two colours and a reload cover it, and it is what our hosting and care service runs for clients.

Have a site with the same problems?

We fix what we write about. Send a few lines and we tell you whether we can help.