A green deploy and a working service are not the same claim. Every deploy in my pipeline now ends with a real request against a real health endpoint on the production domain.
Every deployment tool I have used returns exit 0 the moment the artefact leaves my machine and lands somewhere the platform accepts it. That is a real signal, but it answers a narrower question than the one I actually care about. It tells me the upload succeeded. It does not tell me the service is running, listening on the domain I expect, or capable of answering a request. I learned to stop trusting exit codes as proof of a working deploy after enough near-misses where the build went green and the product was, for practical purposes, down.
The gap is usually routing, not code. A build can compile, package, and upload cleanly while the piece that actually connects a request to that build — a route binding, a DNS record, a load balancer target — sits unset or pointed at the previous version. Nothing in a normal build pipeline checks for that, because routing configuration often lives in a different file, or a different system, than the one the deploy command reads. I have shipped a change, watched the CLI report success, and had the live domain answer with the old version for an embarrassing stretch of time because the route was never actually applied — it existed as a line in a manifest that nothing translates into config.
So now every deploy in my setup ends the same way regardless of platform: a real HTTP request against a real health endpoint, on the actual domain a user would hit, checked after the deploy claims to be finished. Not the platform’s internal preview URL. Not a staging alias. The production hostname, because that is the only one where a routing mistake actually shows up. If the response is not what I expect — wrong status code, wrong version string, timeout — the deploy is not done, whatever the CLI said thirty seconds earlier.
This sounds like an obvious addition and it is a small one to build: a few lines that curl a URL and check the response before the tool prints success. The reason it is worth writing down is that it is easy to skip, because most of the time it would pass anyway, and the deploy already felt finished the moment the upload succeeded. The times it catches something are rare and expensive: a route that silently didn’t bind, a secret the runtime couldn’t read, a container that crashed on startup after a healthy-looking build. Those are exactly the failures a green CI run cannot see, because CI runs before the artefact exists in its deployed form.
The health check has to hit something that proves the specific thing you’re worried about, not just that a server answers at all. A generic 200 from a load balancer’s default page tells you the infrastructure is up, which was never the thing I was worried about — in my experience that layer is rarely the part that breaks. What I want confirmed is that my code, this version, is the thing answering. So the endpoint returns a version marker or a build identifier, something that changes with every deploy, and the check compares it against what I just shipped. A 200 with the wrong version string is a failed deploy wearing a green light.
Treating the health check as the actual definition of "deployed" rather than an optional extra changes how you write the rest of the pipeline. Rollback stops being a separate manual step you remember under pressure and becomes the automatic response to a failed check. The deploy tool already knows the previous working version, because it just watched the same check pass on it. A pipeline that only ever calls itself finished after confirming the live behaviour is the only version of "done" worth trusting, and it is worth the extra thirty seconds every single time.