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({ startHttpServer({
host: config.server.host, host: config.server.host,
port: config.server.port, port: config.server.port,
tls, tls,
listenFile: { path: "/tmp/headplane-listen", url: healthURL },
listener: composeListener({ listener: composeListener({
basename: __PREFIX__, basename: __PREFIX__,
staticRoot: clientDir, staticRoot: clientDir,
+30 -37
View File
@@ -5,27 +5,37 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
) )
// hp_healthcheck pings the Headplane /healthz endpoint. It's invoked // hp_healthcheck pings the Headplane healthz endpoint. It's invoked
// from the Docker HEALTHCHECK directive, so it must work whether the // from the Docker HEALTHCHECK directive inside the same container
// container is serving plain HTTP or has TLS termination enabled. // as Headplane itself.
// //
// Configuration (via env, all optional): // To stay fully zero-config, Headplane writes the exact URL the
// - HEADPLANE_HEALTHCHECK_URL full URL, takes precedence over the // healthcheck should hit — scheme, port, and basename included — to
// pieces below (default: http://localhost:3000/admin/healthz) // listenFile when it starts accepting connections (see
// - HEADPLANE_HEALTHCHECK_TLS "true" to use https:// // runtime/http.ts and app/server/main.ts). This binary just reads
// - HEADPLANE_HEALTHCHECK_HOST default: localhost // that file and GETs the URL verbatim. No env vars, no YAML
// - HEADPLANE_HEALTHCHECK_PORT default: 3000 // parsing, no path-joining, no compile-time knowledge of the
// - HEADPLANE_HEALTHCHECK_PATH default: /admin/healthz // 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() { func main() {
url := healthcheckURL() url := readListenFile()
client := http.Client{ client := http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
Transport: &http.Transport{ 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}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}, },
} }
@@ -43,33 +53,16 @@ func main() {
} }
fmt.Println("Health check passed.") fmt.Println("Health check passed.")
os.Exit(0)
} }
func healthcheckURL() string { func readListenFile() string {
if v := os.Getenv("HEADPLANE_HEALTHCHECK_URL"); v != "" { data, err := os.ReadFile(listenFile)
return v if err != nil {
return defaultURL
} }
url := strings.TrimSpace(string(data))
scheme := "http" if url == "" {
if os.Getenv("HEADPLANE_HEALTHCHECK_TLS") == "true" { return defaultURL
scheme = "https"
} }
return url
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)
} }
+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 and other services. Built-in TLS is meant for the simpler "Headplane on a
single box" scenarios. single box" scenarios.
If you're running Headplane in Docker with TLS enabled, the bundled The bundled Docker healthcheck picks up the right scheme and port
healthcheck binary needs to know it should use HTTPS. Set the following automatically — Headplane writes its loopback URL to `/tmp/headplane-listen`
environment variables on the container: when it starts, and the healthcheck reads it from there. No extra environment
variables, no duplicated config.
- `HEADPLANE_HEALTHCHECK_TLS=true`
- `HEADPLANE_HEALTHCHECK_PORT` (if you've changed it from the default `3000`)
## Reverse Proxying ## Reverse Proxying
+16 -1
View File
@@ -4,7 +4,7 @@
// React Router request listener from `@react-router/node`) and serves // React Router request listener from `@react-router/node`) and serves
// static assets out of a directory. // static assets out of a directory.
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises"; import { stat, writeFile } from "node:fs/promises";
import { import {
type IncomingMessage, type IncomingMessage,
type RequestListener, type RequestListener,
@@ -172,6 +172,14 @@ export interface StartOptions {
listener: RequestListener; listener: RequestListener;
tls?: HttpsServerOptions; tls?: HttpsServerOptions;
logger?: Logger; 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 * Optional async hook invoked on SIGINT/SIGTERM after the HTTP
* server stops accepting new connections but before the process * 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, () => { server.listen(opts.port, opts.host, () => {
const proto = opts.tls ? "https" : "http"; const proto = opts.tls ? "https" : "http";
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port); 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) => { const shutdown = async (signal: string) => {