Portainer stack docs and a real container healthcheck, slice 4 of M4

The README deploy section documents the GHCR image, the compose stack
that joins Caddy's external network, and that the Caddyfile entry is
managed by hand on the server. distroless has no shell, so the
healthcheck is the binary itself: a new -check flag probes /healthz
and exits 0 or 1, and the Dockerfile bakes it in as HEALTHCHECK.
Verified locally: the container reports healthy.
This commit is contained in:
Justin Visser 2026-06-11 14:47:29 +02:00
parent 51af5acf6a
commit 05b01348d5
3 changed files with 79 additions and 2 deletions

View file

@ -26,6 +26,9 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /spreadlab ./cmd/spread
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /spreadlab /spreadlab COPY --from=build /spreadlab /spreadlab
EXPOSE 8080 EXPOSE 8080
# No shell or curl in distroless, so the healthcheck is the binary
# probing its own /healthz (the -addr default targets localhost:8080).
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD ["/spreadlab", "-check"]
# 0.0.0.0, not the dev default localhost: inside a container the # 0.0.0.0, not the dev default localhost: inside a container the
# loopback interface is unreachable from outside. # loopback interface is unreachable from outside.
ENTRYPOINT ["/spreadlab", "-addr", ":8080"] ENTRYPOINT ["/spreadlab", "-addr", ":8080"]

View file

@ -49,8 +49,9 @@ Early development; interface and API are not stable yet.
- [x] Simulation engine in Go (tested, deterministic, benchmarked) - [x] Simulation engine in Go (tested, deterministic, benchmarked)
- [x] JSON API + TypeScript types generated from the Go structs - [x] JSON API + TypeScript types generated from the Go structs
- [x] Web frontend reproducing the three-scenario comparison from live data - [x] Web frontend reproducing the three-scenario comparison from live data
- [ ] Interactive dashboard (controls, network view, spread animation) - [x] Interactive dashboard (controls, network view, spread animation)
- [ ] Single-binary deploy (embedded frontend), Docker image, hosted demo - [x] Single-binary deploy (embedded frontend), public Docker image
- [ ] Hosted demo
- [ ] Intervention optimisation under a budget - [ ] Intervention optimisation under a budget
## Quick start (development) ## Quick start (development)
@ -75,6 +76,41 @@ cd web && npm run dev # frontend on localhost:5173
`go run ./cmd/spreadlab -table` prints the three-scenario comparison to the `go run ./cmd/spreadlab -table` prints the three-scenario comparison to the
terminal as a quick engine sanity check. terminal as a quick engine sanity check.
## Deploy
Every merge to main publishes a public image to
`ghcr.io/justinzeus/spreadlab`, tagged `latest` and the commit SHA (for
rollbacks). The image is self-contained: one Go binary with the built
frontend embedded, serving the dashboard on `/`, the API under `/api`,
and `/healthz`. A healthcheck is baked in (the binary probes its own
`/healthz`; the distroless base has no shell).
The production setup is a Portainer stack behind Caddy. The stack joins
the reverse proxy's external docker network, so no ports are published;
Caddy reaches the app at `spreadlab:8080` over that network:
```yaml
services:
spreadlab:
image: ghcr.io/justinzeus/spreadlab:latest
container_name: spreadlab
restart: unless-stopped
networks:
- caddy
networks:
caddy:
external: true
```
The network name must match the one Caddy actually uses (`docker network
ls` on the server). The Caddyfile entry itself is managed by hand on the
server, not in this repo: add a site block for the public hostname that
reverse-proxies to `spreadlab:8080`, and reload Caddy.
To run the image anywhere else: `docker run -p 8080:8080
ghcr.io/justinzeus/spreadlab:latest`.
## Project layout ## Project layout
| Path | What it is | | Path | What it is |

View file

@ -9,9 +9,11 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time"
"github.com/JustinZeus/spreadlab/internal/api" "github.com/JustinZeus/spreadlab/internal/api"
"github.com/JustinZeus/spreadlab/internal/engine" "github.com/JustinZeus/spreadlab/internal/engine"
@ -21,6 +23,7 @@ import (
func main() { func main() {
addr := flag.String("addr", "localhost:8080", "address to serve the API on") addr := flag.String("addr", "localhost:8080", "address to serve the API on")
table := flag.Bool("table", false, "print the three-scenario comparison and exit") table := flag.Bool("table", false, "print the three-scenario comparison and exit")
check := flag.Bool("check", false, "probe a running server's /healthz and exit 0 or 1")
flag.Parse() flag.Parse()
if *table { if *table {
@ -31,6 +34,16 @@ func main() {
return return
} }
// The runtime image has no shell or curl, so the compose healthcheck
// runs this same binary against the server instance.
if *check {
if err := probeHealthz(*addr); err != nil {
fmt.Fprintln(os.Stderr, "spreadlab:", err)
os.Exit(1)
}
return
}
// One mux, one origin: the longer /api/ pattern wins over / for API // One mux, one origin: the longer /api/ pattern wins over / for API
// calls, everything else falls through to the embedded frontend. // calls, everything else falls through to the embedded frontend.
mux := http.NewServeMux() mux := http.NewServeMux()
@ -46,6 +59,31 @@ func main() {
} }
} }
// probeHealthz asks a running server whether it is healthy. The -addr
// flag doubles as the target, so the healthcheck and the server agree
// on the port by using the same default. A "localhost:8080" style addr
// (or ":8080", where the host part is empty) becomes a loopback URL.
func probeHealthz(addr string) error {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid addr %q: %w", addr, err)
}
if host == "" {
host = "localhost"
}
client := http.Client{Timeout: 3 * time.Second}
response, err := client.Get(fmt.Sprintf("http://%s/healthz", net.JoinHostPort(host, port)))
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return fmt.Errorf("healthz answered %s", response.Status)
}
return nil
}
// run executes the three-scenario comparison in the default world and // run executes the three-scenario comparison in the default world and
// writes the table to out. Formatting is pure string building; the single // writes the table to out. Formatting is pure string building; the single
// write at the end is the only error to handle. // write at the end is the only error to handle.