The deploy succeeded. That is not the same as the deploy working
A green pipeline reports that a process started. It is compatible with an empty database, a missing environment variable, a stale CDN and every real route returning 500. Only a request from outside settles it.
A pipeline that goes green asserts five things. The image built. The image was pushed. The orchestrator accepted the new revision. The new containers reached the state the orchestrator calls ready. The old ones stopped.
Every one of those is a statement about your release machinery, and not one of them is a statement about whether a person can use your website. The pipeline is not wrong. It answered the question it was asked, and nobody ever pointed it at the one you actually have.
What a successful deploy is compatible with
This is the useful exercise: hold the pipeline green and ask what could still be true.
An empty database. The migration step ran against the wrong connection string, or ran against a fresh instance, or the seed step was skipped in this environment. The application starts perfectly — it has no opinion about how many rows exist. Every page renders its empty state, which in a well-built product looks like a design decision rather than a disaster.
A missing environment variable. A new setting was added to the code and to
the local .env and not to the deployed secret store. If it is read at startup
with a default, the process starts and behaves subtly wrongly. If it is read
lazily, the process starts and the first request that touches that code path
throws — which is to say, the process is healthy and the feature is dead.
A certificate that did not renew. The deploy has nothing to do with the certificate. The renewal has been failing since a DNS challenge broke, the notification goes to an alias nobody reads, and the release you just shipped lands behind a TLS handshake that browsers now refuse. Deployment succeeded. The site is unreachable.
A CDN serving the previous build. The origin has the new bundle. The edge
has a cached copy of the old HTML with long Cache-Control, and it is serving
that to everyone. Your deploy is live and no visitor is receiving it. The
nastier variant is a partial one: new HTML, cached old JavaScript, hashed asset
names that no longer match, and a page that loads and then does nothing.
A health endpoint returning 200 while every real route 500s. This one
deserves the most attention, because it is the mechanism the rest of the system
trusts. A readinessProbe pointed at a /healthz that returns a static OK
proves the HTTP server is listening. It does not touch the database, the
template engine, the session store, the object storage or a single line of your
own code. So the pods are marked ready, the service starts routing to them, the
previous revision is retired and the rollout reports success — while /,
/pricing and /login all throw. Nothing malfunctioned. The probe was told to
check the wrong thing, and it checked it perfectly.
A rollout that only half happened. With maxUnavailable set, a rollout is
declared complete once enough replicas report ready — the rest can be
crash-looping behind the same service. Traffic is load-balanced across the
survivors and the failures, so the site is broken for a fraction of requests
rather than all of them. Intermittent is worse than total: whoever checks once
and lands on a working instance reports that it is fine.
None of these are exotic, and they share one property: no part of the release process can detect them, because in every case the release process did its job.
The gap is structural, not a gap in tooling
The instinct is to add a better health check. Make /healthz query the
database. Have it render a template. Add a smoke test to the pipeline.
Those are improvements and you should make them. They do not close the gap, for a reason that does not go away with effort: a check that runs inside the deployment inherits the deployment's context. It runs on the cluster network, so it never traverses the CDN or the public load balancer. It usually connects over plain HTTP internally, so it never performs the TLS handshake that is broken. It resolves an internal name or a service IP, so a public DNS record still pointing at the old load balancer is invisible to it. And it runs with the environment the deploy provides, which is by definition the environment that looked correct to whoever configured it.
Most of the failures above live in the space between the container and the visitor. Nothing that stays inside that boundary can see them.
There is also a timing problem. A pipeline smoke test runs once, at the moment of the deploy. A CDN that will serve stale HTML for six hours is not stale yet when the test runs. A certificate expiring on Thursday passes on Tuesday. Deploy-time verification is a snapshot, and several of these failures are functions of time rather than of the release.
The only honest confirmation
The confirmation that a deploy worked is an unprivileged request from outside, to the public hostname, made by something that shares nothing with your infrastructure — and repeated, because the answer changes without you.
That is not a philosophy. It is a list of specific requests, and it maps directly onto the failures above:
| What could be wrong | What settles it from outside |
|---|---|
Every real route 500s behind a green /healthz |
http-uptime against the routes people use, not the health path |
| The page is up and the content is a stack trace or an empty state | dom-content asserting a string that only appears when it worked |
| The certificate did not renew | ssl-cert-expiry and cert-chain-trust on the presented certificate |
| The CDN is serving the previous build | cache-policy on the HTML document, and browser-render on a page that needs its bundle |
| The public name points at the old load balancer | dns-resolution |
| Half the replicas are crash-looping | availability-sample — repeated probes distinguish flapping from down |
What every row has in common: the request carries no credentials, no session and no cluster network. It is the request your visitors make, which is the only one whose answer is the thing you wanted to know.
Where the line is
An external check will not tell you why. It sees a 500, not the exception; a timeout, not the exhausted connection pool. It runs on a schedule, so there is a window between a deploy going wrong and the check noticing. It cannot see anything behind a login. Your logs, metrics and traces remain the tools for diagnosis, and nothing outside your network substitutes for them.
But diagnosis is the second question. The first is whether the site your customers are looking at right now is the site you think you shipped — and that one is settled by making a request from outside, or it is not settled at all. HarpyWatch is one way to make it, on a schedule, with the same limits above. What matters is that something makes it.
Cite this
The HarpyWatch team. “The deploy succeeded. That is not the same as the deploy working”. The Nest, HarpyWatch, 2 September 2026 UTC. https://harpywatch.com/blog/the-deploy-succeeded-and-the-site-is-down
More from The Nest
The agent will make the check pass
Given a red signal, an agent optimises for the signal. That is not a flaw in any particular tool — it is what optimisation means, and it changes which signals are worth having.
Your monitor cannot live in the thing it monitors
A health check inside your own stack goes silent at exactly the moment it had something to say. Here are the failure domains an internal check shares with the site, and what an external one sees instead.
The agent shipped it and nobody read it
An AI agent can produce a working-looking site faster than any human can review it. The defects live in the gap between the agent saying it is done and a stranger being able to use it.