HostPack — the build engine
HostSSH's branded build engine — a brand + policy layer over Railpack (MIT) on BuildKit, with a slim/distroless base-image policy that takes a 508 MB app image to ~140 MB. Phased wrap → fork → original.
HostPack — the build engine
HostPack is a brand + policy layer over the engine we already wrap — not a new engine. The build
stage is Railpack (MIT, on BuildKit + Mise, no Nix). HostPack is a drop-in implementation of the
existing two-method builder.Builder interface, so the whole change is additive with zero call-site
churn, plus a base-image policy that shrinks what lands on the machine.
Verified against live code:
builder.Builderis exactlyBuild(ctx, Request, io.Writer) (*Result, error)
Name() string(builder.go);Railpackimplements it by shellingrailpack build --name <image> [--env k=v…] <source>viaexec.CommandContext(railpack.go); the only construction site is deploy.go:36 (builder.NewRailpack()), andName()is printed at deploy.go:55. No code dispatches on the concrete type — it's a wrap, not a coupling.
Phased strategy (wrap → fork → original)
Phase 1 — Brand + wrap (now, ~½ day, zero engine code)
- Rename
railpack.go→hostpack.go, typeRailpack→HostPack,Name()returns"hostpack". TheBuildbody is unchanged — a renamedhostpackbinary has the identicalbuild --name --env <src>CLI; in Phase 1hostpackonPATHis a symlink to the pinned upstreamrailpack. - Binary override chain in
NewHostPack():HOSTPACK_BIN→RAILPACK_BIN(transitional) →"hostpack". One env var flips to the fork later, no recompile. - Flip the single wiring at deploy.go:36 →
builder.NewHostPack()(optionallyHOSTSSH_BUILDER-gated for a reversible rollout). - Fix the cosmetic brand strings:
mcp.go:126,cli.go:44(hardcoded "Railpack"), and the deploy.go:33 comment. The deploy.go:55 progress line auto-rebrands (it printsName()). - Re-point base images without forking via
railpack.json/env so the runtime stops pullingghcr.io/railwayapp/*→ supply-chain + brand control with no code fork.
Exit: ▸ build (hostpack) prints; MCP/CLI say HostPack; runtime pulls our registry; build still
succeeds on metal (.223).
Phase 2 — Vendor + fork to Girard-Media/hostpack (only on a concrete trigger)
Fork only when one fires: need a custom provider; need a non-Debian base baked into the engine; want to
drop the host docker load dependency (BuildKit ExporterImage→registry push); or upstream
stalls/relicenses. Then: import upstream keeping its MIT LICENSE verbatim; rename the binary; re-point
the two engine image constants (core/plan/plan.go:10 RailpackRuntimeImage,
core/generate/mise_step_builder.go:26 RailpackBuilderImage) to registry.girardmedia.com/hostpack/*.
Anti-drift is the load-bearing decision. Upstream cuts releases every few days (v0.30.0 Jun 22,
v0.29.0 Jun 18). Stay on the pinned-release-binary boundary as long as possible to inherit security
patches for free. Once forked: weekly CI fetch upstream && merge upstream/main on a sync/upstream
branch → build → integration tests → go-licenses check → open a PR (never straight to main). Keep the
diff additive (two image constants, binary name, NOTICE, net-new provider packages); never edit
buildkit/build_llb or upstream provider logic in place — that's the high-churn merge surface.
Phase 3 — Original providers (optional, only on a concrete trigger)
Each is a self-contained package implementing the Provider interface, appended after upstream in
GetLanguageProviders() so upstream keeps winning where it works and ours only fires on its own
Detect(). Re-implementing the 14 existing providers is explicitly out of scope.
Branding
Required version banner (satisfies MIT notice-retention in a user-visible place):
HostPack <semver> (HostSSH build engine · powered by Railpack <ver> (MIT) + BuildKit <ver> (Apache-2.0)).
Images: registry.girardmedia.com/hostpack/{builder,runtime,frontend}:mise-<ver> (digest-pinned).
Base-image policy — the size win (508 MB → ~140 MB)
A config layer over the wrapped engine, enforced in three places: a BuildProfile
(slim|distroless|scratch|debug, defaulted per detected language) on Request/RunSpec; HostPack-owned
digest-pinned bases the engine points at; and a CI/agent image-size gate.
Two levers, every language:
- A — Mandatory multi-stage build→runtime split. Root cause of the 508 MB trivial Node image (and 5–8 GB fleet apps) is the Nixpacks lineage shipping the full toolchain into the runtime. Build stage on the fat base (compilers/headers) → install/compile/prune; runtime stage on slim/distroless/scratch → COPY only the artifact + prod deps + interpreter. Halves-to-quarters before the base family even changes.
- B — Digest-pinned shared bases. containerd dedups content-addressed layers: every app
FROMthe same@sha256→ one ~150 MB base stored once per host, patched in one place.
| Language | Runtime base | Target | Hard cap |
|---|---|---|---|
| Go / Rust / static | distroless/static (ships CA certs + tzdata) or scratch + cert bundle | 2–20 MB | 30 MB |
| Node (pure JS) | build node:22-slim → run distroless/nodejs22 | 120–160 MB | 200 MB |
| Node + native (sharp/prisma) | build node:22-slim → run distroless/nodejs or slim | 150–220 MB | 280 MB |
| Python | build python:3.12-slim → run distroless/python3 or slim+venv | 80–180 MB | 250 MB |
| Ruby | build full ruby → run ruby-slim | 120–220 MB | 300 MB |
| PHP | php:8.x-fpm-slim + tiny web layer | 100–180 MB | 280 MB |
slim is the safe default (glibc keeps native deps working + has a shell for healthchecks/exec);
distroless/scratch are opt-in smaller; debug is opt-up. Avoid Alpine except for static Go/Rust + pure-JS
Node — musl ≠ manylinux means pip/native rebuilds from source (slower and often larger). Size gate:
FLAG (warn) for novices, FAIL only in CI/advanced, so legit-large apps (ML models) aren't blocked.
Phase 0 — prose-not-code prerequisites (must ship first)
The design verified that four "robustness defaults" assumed by the plan do not exist in code — and without them, slim is the only safe profile and distroless/scratch can't be offered. Ship these first:
- PID1 / SIGTERM / zombies — runtime.go emits
docker run -dwith no--init. AddInit booltoRunSpec(default true) → append--init. Without it, bare node/python as PID1 hangsdocker stop→ unclean shutdown → write corruption on every redeploy. - Healthcheck without a shell — runtime.go's
Exec-based readiness probe is impossible on distroless. (The Traefikloadbalancer.healthcheck.{path,interval,timeout}labels + the agent-side HTTP GET readiness probe have since shipped: proxy.goLabels()emits them, probing the app's--readiness-pathover the network, falling back to/— see guard G24.) - musl/glibc mixing (the #1 silent segfault) — make the build→run base pairing agent-owned and digest-pinned (glibc-build + glibc-runtime), reject musl-vs-glibc plans, add a post-build dynamic-link smoke probe. (Note: the database module currently defaults to Alpine — keep that scoped to stateful services, not built apps.)
- CA certs / tzdata on scratch — default the smallest profile to
distroless/static(ships both); pure scratch is an advanced opt-in that must COPY the cert bundle + zoneinfo (or Gotimetzdata). - Out-of-the-box build — install.sh installs only Docker + the agent today; it does not install
hostpack, start
buildkitd, exportBUILDKIT_HOST, or pre-pull bases. Add all four + ahostssh doctorpreflight that gates deploy on BuildKit reachability. (This is exactly the manual setup we did by hand on.223— it must become automatic.)
License & attribution
Railpack MIT, Mise MIT, and every transitive dep permissive (BuildKit/containerd/docker-cli/x-*
Apache-2.0/BSD; urfave/cli, charmbracelet, toml, uuid MIT/BSD) — no GPL/LGPL/AGPL/SSPL. NOTICE must be
mechanical, not hand-authored: extend the existing go-licenses gate (go-licenses save ./... --save_path=third_party over the agent and, post-fork, the hostpack module; bundle = the union) and
go-licenses check with the PLATFORM.md allowlist, wired into the weekly upstream-sync PR so
a future BuildKit/containerd bump dragging in copyleft is caught at PR. Base images are a separate license
domain go-licenses can't see → digest-pin all bases; repin distroless off the dead gcr.io/distroless
path (GCR retired 2025) to cgr.dev/chainguard by digest + a registry.girardmedia.com fallback mirror.
Image retention (reuse the existing contract)
Keep retention.go unchanged (restic-repo retention, {KeepAll:true, MinKeep:1}, Preview-before-Enforce,
no silent deletion). Add a sibling Docker image-tag pruner with the identical Policy shape scoped to
hostssh/<app>:* (MinKeep guarantees the live tag is never pruned), plus an always-on safe reaper
(docker image prune dangling-only + bounded docker builder prune) — untagged garbage only, which
directly reclaims the fleet's 77%-disk problem and falls outside the no-silent-deletion rule.
The all-in-one one-click package
curl … | sh installs: the agent (~7 MB static ELF) + pinned hostpack binary + managed buildkitd
(BUILDKIT_HOST exported) + container runtime + pre-pulled digest-pinned hostpack builder+runtime bases +
Traefik (LE certs) + the license/activation hook. Outcome: hostssh deploy --source <repo> --domain <d> →
~140 MB image + live TLS app, zero further config.
See also: PLATFORM · agent-protocol · hardening