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.Builder is exactly Build(ctx, Request, io.Writer) (*Result, error)

  • Name() string (builder.go); Railpack implements it by shelling railpack build --name <image> [--env k=v…] <source> via exec.CommandContext (railpack.go); the only construction site is deploy.go:36 (builder.NewRailpack()), and Name() 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)

  1. Rename railpack.gohostpack.go, type RailpackHostPack, Name() returns "hostpack". The Build body is unchanged — a renamed hostpack binary has the identical build --name --env <src> CLI; in Phase 1 hostpack on PATH is a symlink to the pinned upstream railpack.
  2. Binary override chain in NewHostPack(): HOSTPACK_BINRAILPACK_BIN (transitional) → "hostpack". One env var flips to the fork later, no recompile.
  3. Flip the single wiring at deploy.go:36 → builder.NewHostPack() (optionally HOSTSSH_BUILDER-gated for a reversible rollout).
  4. 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 prints Name()).
  5. Re-point base images without forking via railpack.json/env so the runtime stops pulling ghcr.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 FROM the same @sha256 → one ~150 MB base stored once per host, patched in one place.
LanguageRuntime baseTargetHard cap
Go / Rust / staticdistroless/static (ships CA certs + tzdata) or scratch + cert bundle2–20 MB30 MB
Node (pure JS)build node:22-slim → run distroless/nodejs22120–160 MB200 MB
Node + native (sharp/prisma)build node:22-slim → run distroless/nodejs or slim150–220 MB280 MB
Pythonbuild python:3.12-slim → run distroless/python3 or slim+venv80–180 MB250 MB
Rubybuild full ruby → run ruby-slim120–220 MB300 MB
PHPphp:8.x-fpm-slim + tiny web layer100–180 MB280 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:

  1. PID1 / SIGTERM / zombies — runtime.go emits docker run -d with no --init. Add Init bool to RunSpec (default true) → append --init. Without it, bare node/python as PID1 hangs docker stop → unclean shutdown → write corruption on every redeploy.
  2. Healthcheck without a shell — runtime.go's Exec-based readiness probe is impossible on distroless. (The Traefik loadbalancer.healthcheck.{path,interval,timeout} labels + the agent-side HTTP GET readiness probe have since shipped: proxy.go Labels() emits them, probing the app's --readiness-path over the network, falling back to / — see guard G24.)
  3. 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.)
  4. 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 Go timetzdata).
  5. Out-of-the-box build — install.sh installs only Docker + the agent today; it does not install hostpack, start buildkitd, export BUILDKIT_HOST, or pre-pull bases. Add all four + a hostssh doctor preflight 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