Health and Readiness
An orchestrator needs two different questions answered. Is this process alive, or should it be killed and replaced? And can this process take traffic right now? They have different answers during a rolling deploy, which is why they are two endpoints:
[server]health = "/healthz"readiness = "/readyz"Neither key has a default. An application that answers on /healthz should say
so where an operator reading its configuration will see it — a default would
leave endpoints running that no file in the repository mentions. An unset key
registers no route at all.
pw init writes both into config.dev.toml, so a scaffolded project has them
in development and states them explicitly for every other environment.
What each one answers
Section titled “What each one answers”health is liveness. It checks nothing and depends on nothing. If the
process can accept a connection and run a handler, it answers 200. That is the
correct definition: a dependency outage is not a reason to kill and restart your
application, and a liveness probe that fails when the database is down turns one
outage into a restart loop.
readiness pings every configured database pool — each connection in the
group set, or the single pool — with a one-second bound over the whole check.
Any pool that does not answer makes the endpoint 503, and the orchestrator
stops routing to this instance until it recovers.
Both respond identically otherwise:
| Methods | GET and HEAD; anything else is 405 with Allow |
| Success | 200, body ok |
| Failure | 503, body unavailable |
| Content-Type | text/plain; charset=utf-8 |
| Cache-Control | no-store |
The body is two words on purpose. These endpoints bypass session and authentication, so they are reachable by anything that can reach the port, and they reveal only status — never a DSN, a backend name, a stack trace, or a configuration value. A probe that leaks the shape of your infrastructure to an unauthenticated caller is a worse problem than the one it was added to detect.
Probing from a shell-less container
Section titled “Probing from a shell-less container”Docker’s HEALTHCHECK runs a command inside the container, and the command
everyone reaches for is curl. A distroless or scratch image — the right way to
ship a Go binary — has no curl and no shell to run it with, which leaves the
instruction nothing to call. The application binary fills that role itself:
HEALTHCHECK CMD ["/myapp", "healthcheck"]The subcommand reads the same configuration sources as the server — the TOML
file, the environment, PORT — so the port and the health path are never
repeated in the Dockerfile. It issues one GET against the loopback and exits
0 on a 2xx answer. Anything else — another status, a refused connection, a
timeout — exits 1, which Docker counts as unhealthy. Exit code 2 is never
used; Docker reserves it.
Two options adjust the probe. --ready targets the readiness path instead,
which makes the verdict include the database pools — appropriate where a
dependent service should wait for a database-ready instance, and too strict for
a restart policy, where a database outage would turn into a restart loop.
--timeout bounds the whole probe and defaults to 3s, safely inside Docker’s
own 30-second default, so a hung listener is reported as unhealthy rather than
the probe being killed and reported as nothing.
The probe needs server.health (or server.readiness under --ready) set in
the environment’s configuration, and a fixed server.port; an unset path or
port 0 fails with a message naming the key. The subcommand name is reserved —
pw.RegisterSubCommand panics at
startup if an application claims healthcheck for itself.
Dockerfile
Section titled “Dockerfile”The probe is the binary the image already ships, so the instruction stays in exec form and never asks for a shell:
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \ CMD ["/app/myapp", "healthcheck"]Docker’s --timeout=5s is its patience with the whole command; the probe’s own
default of 3s finishes inside it, so the verdict is always the probe’s exit
code, never a kill. The environment’s configuration must set server.health —
a missing key fails the probe with a message naming it, so the misconfiguration
surfaces on the first interval instead of never.
pw init writes this line into the project’s Dockerfile already, along with the
config.prod.toml that sets the key.
Container Images walks through the rest
of that file, including why a Popcorn Web image cannot be built with COPY and
go build.
Compose
Section titled “Compose”Compose takes the same exec form in healthcheck.test, and this is where
--ready earns its place: depends_on with service_healthy holds a
dependent service back, and a gate that means “the databases behind it answer”
is the readiness path, not liveness:
services: app: image: myapp:latest environment: PORT: "8080" healthcheck: test: ["CMD", "/myapp", "healthcheck", "--ready"] interval: 10s timeout: 5s retries: 3 start_period: 10s importer: image: myapp:latest command: ["import", "/data/users.csv"] depends_on: app: condition: service_healthyA healthcheck declared here overrides the image’s HEALTHCHECK line, so the
image can keep the plain liveness probe for docker run while Compose asks the
stricter question its dependency graph needs.
Kubernetes
Section titled “Kubernetes”Kubernetes ignores HEALTHCHECK entirely and probes over HTTP from outside
the container, so the subcommand stays out of the manifest — point the probes
at the endpoints themselves:
containers: - name: app image: myapp:latest ports: - containerPort: 8080 env: - name: APP_ENV value: prod livenessProbe: httpGet: path: /healthz port: 8080 readinessProbe: httpGet: path: /readyz port: 8080The split maps one to one: livenessProbe on health restarts the pod only
when the process itself has stopped answering, and readinessProbe on
readiness takes the pod out of the Service until its pools answer. An image
that carries a HEALTHCHECK line does no harm here — it is simply never run.
The OpenAPI endpoint
Section titled “The OpenAPI endpoint”[server]openapi = "/openapi.json"api_doc = "scalar"api_doc_path = "/docs"The same listener can serve the generated OpenAPI document and a UI over it.
Both follow the same rule as the probes: no default path, and an unset key
serves nothing. api_doc additionally requires openapi — a UI over a document
nobody serves has nothing to render.
Where they differ is access. The probes are answered above every extension, which is what makes them unauthenticated by construction. The documentation endpoints sit below the extension chain, so the session and the authentication guard reach them exactly as they reach an application route:
[auth.protection]include = ["/openapi.json", "/docs"]Protection is opt-in, so an unlisted path stays public — but a test that authenticates can now read the document the same way it reads its own routes, and a closed deployment can put its API description behind a login without moving it off the primary listener.
Setting it per environment is still worth doing. pw init writes
api_doc = "scalar" into config.dev.toml only, and the default is empty,
which keeps the reference private until a staging or production config
deliberately opts in. See API Documentation,
and pw doctor, which reports an exposed documentation
endpoint as a readiness finding for a deployed environment.
Collisions fail startup
Section titled “Collisions fail startup”Every operational path is validated against the application’s routes before the listener opens. A route that collides with an enabled endpoint is a startup error, not a silent shadowing — you find out at deploy time rather than from a probe that has been reporting on the wrong handler.
Shutting down
Section titled “Shutting down”On SIGINT or SIGTERM the server stops accepting new connections and lets
in-flight requests finish, bounded by:
[server]shutdown_timeout = "10s"Runtime resources — database pools and anything else registered for cleanup — are closed after that, under the same bound.
Requests still in flight when the timeout expires are cut off. Size the value against your slowest ordinary request, and remember that a stream is designed to stay open far longer than that: a long-lived stream will be terminated by shutdown, and the client is expected to reconnect.
The readiness endpoint does not start failing when shutdown begins. Draining is the orchestrator’s job — it stops sending traffic, then sends the signal — and by the time the signal arrives, the listener is already closing.
