feat: automate health check with a written file

This commit is contained in:
Aarnav Tale
2026-05-30 20:45:15 -04:00
parent ea27c846e2
commit b95d601ff6
4 changed files with 57 additions and 44 deletions
+7
View File
@@ -42,10 +42,17 @@ if (certPath || keyPath) {
}
}
// Read by the bundled Docker healthcheck. Includes the basename
// (`__PREFIX__`) so the Go binary can stay completely dumb — just
// GET whatever URL is in this file. `/tmp` is writable in
// distroless and in every dev shell we care about.
const healthURL = `${tls ? "https" : "http"}://127.0.0.1:${config.server.port}${__PREFIX__}/healthz`;
startHttpServer({
host: config.server.host,
port: config.server.port,
tls,
listenFile: { path: "/tmp/headplane-listen", url: healthURL },
listener: composeListener({
basename: __PREFIX__,
staticRoot: clientDir,
+30 -37
View File
@@ -5,27 +5,37 @@ import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
// hp_healthcheck pings the Headplane /healthz endpoint. It's invoked
// from the Docker HEALTHCHECK directive, so it must work whether the
// container is serving plain HTTP or has TLS termination enabled.
// hp_healthcheck pings the Headplane healthz endpoint. It's invoked
// from the Docker HEALTHCHECK directive inside the same container
// as Headplane itself.
//
// Configuration (via env, all optional):
// - HEADPLANE_HEALTHCHECK_URL full URL, takes precedence over the
// pieces below (default: http://localhost:3000/admin/healthz)
// - HEADPLANE_HEALTHCHECK_TLS "true" to use https://
// - HEADPLANE_HEALTHCHECK_HOST default: localhost
// - HEADPLANE_HEALTHCHECK_PORT default: 3000
// - HEADPLANE_HEALTHCHECK_PATH default: /admin/healthz
// To stay fully zero-config, Headplane writes the exact URL the
// healthcheck should hit — scheme, port, and basename included — to
// listenFile when it starts accepting connections (see
// runtime/http.ts and app/server/main.ts). This binary just reads
// that file and GETs the URL verbatim. No env vars, no YAML
// parsing, no path-joining, no compile-time knowledge of the
// basename.
//
// If the file is missing (e.g. the server hasn't finished booting
// on the very first probe, or this is an old image being run with
// a new healthcheck) we fall back to the historical default.
const (
listenFile = "/tmp/headplane-listen"
defaultURL = "http://localhost:3000/admin/healthz"
)
func main() {
url := healthcheckURL()
url := readListenFile()
client := http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
// We just care that the server is alive and with us
// Self-signed certs are normal for in-process TLS termination
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
@@ -43,33 +53,16 @@ func main() {
}
fmt.Println("Health check passed.")
os.Exit(0)
}
func healthcheckURL() string {
if v := os.Getenv("HEADPLANE_HEALTHCHECK_URL"); v != "" {
return v
func readListenFile() string {
data, err := os.ReadFile(listenFile)
if err != nil {
return defaultURL
}
scheme := "http"
if os.Getenv("HEADPLANE_HEALTHCHECK_TLS") == "true" {
scheme = "https"
url := strings.TrimSpace(string(data))
if url == "" {
return defaultURL
}
host := os.Getenv("HEADPLANE_HEALTHCHECK_HOST")
if host == "" {
host = "localhost"
}
port := os.Getenv("HEADPLANE_HEALTHCHECK_PORT")
if port == "" {
port = "3000"
}
path := os.Getenv("HEADPLANE_HEALTHCHECK_PATH")
if path == "" {
path = "/admin/healthz"
}
return fmt.Sprintf("%s://%s:%s%s", scheme, host, port, path)
return url
}
+4 -6
View File
@@ -113,12 +113,10 @@ For most deployments we still recommend terminating TLS at a reverse proxy
and other services. Built-in TLS is meant for the simpler "Headplane on a
single box" scenarios.
If you're running Headplane in Docker with TLS enabled, the bundled
healthcheck binary needs to know it should use HTTPS. Set the following
environment variables on the container:
- `HEADPLANE_HEALTHCHECK_TLS=true`
- `HEADPLANE_HEALTHCHECK_PORT` (if you've changed it from the default `3000`)
The bundled Docker healthcheck picks up the right scheme and port
automatically — Headplane writes its loopback URL to `/tmp/headplane-listen`
when it starts, and the healthcheck reads it from there. No extra environment
variables, no duplicated config.
## Reverse Proxying
+16 -1
View File
@@ -4,7 +4,7 @@
// React Router request listener from `@react-router/node`) and serves
// static assets out of a directory.
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { stat, writeFile } from "node:fs/promises";
import {
type IncomingMessage,
type RequestListener,
@@ -172,6 +172,14 @@ export interface StartOptions {
listener: RequestListener;
tls?: HttpsServerOptions;
logger?: Logger;
/**
* If set, writes `url` to `path` once the server is accepting
* connections. Used by the bundled Docker healthcheck binary to
* discover the right scheme, port, and basename without any
* duplicate configuration. The caller assembles the URL so that
* the runtime stays generic.
*/
listenFile?: { path: string; url: string };
/**
* Optional async hook invoked on SIGINT/SIGTERM after the HTTP
* server stops accepting new connections but before the process
@@ -194,6 +202,13 @@ export function startHttpServer(opts: StartOptions): Server {
server.listen(opts.port, opts.host, () => {
const proto = opts.tls ? "https" : "http";
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
if (opts.listenFile) {
const { path, url } = opts.listenFile;
writeFile(path, `${url}\n`, "utf8").catch((err) => {
log.error("Failed to write listen file %s: %s", path, err);
});
}
});
const shutdown = async (signal: string) => {