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.

article By 6 min read

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.

article 6 min read

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.

article 6 min read

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.

article 6 min read