Deploy from git push to a single cheap server, with zero downtime.
One binary that is your reverse proxy, your deploy pipeline, your rate
limiter, and your page cache, with its config and secrets kept in git
— for indie hackers, solo devs, and small
businesses running real apps on cheap servers. No Docker, no
Kubernetes, no SSH keys in CI. Powered by
Caddy.
git push → CI builds app.tar.gz → webhook → hotserve swaps it live. Zero downtime.
Built in:
| Module | What it does |
|---|---|
| liveswap | Zero-downtime app deploys: webhook from CI, artifact download, migrations, health-gated start, atomic traffic cutover, graceful stop, versioned releases with rollback. Your apps run as systemd units under the hotserve user's own service manager — Node.js, Go, anything that can listen on a unix socket — surviving hotserve restarts and upgrades, with a continuous watchdog that restarts them on crash or sustained health failure (a restart budget and backoff bound the rate; it throttles rather than giving up on anything a retry can fix). |
| penaltybox | Rate limiting driven by your app's X-Rate-Limit-Level hint headers — weighted sliding-window budgets, tiers, and a penalty box for clients that cross them. |
| box | Config from git: the box's Caddyfile lives in a repository of its own, and each signed push to main reaches the box, which checks the commit's signature, validates the file, installs it as root, and rolls back a reload that fails. The apps' secrets ride along, encrypted to the box's own key. CI holds no SSH key. |
| cache | HTTP page caching via Souin with in-memory Otter storage. |
| everything Caddy has | Automatic HTTPS, HTTP/2 + HTTP/3, the Caddyfile, the admin API — hotserve is Caddy underneath, with the modules above compiled in. |
A .deb for amd64 and arm64 on the
releases page.
Your first deploy installs it and deploys an
app, in four steps; Upgrading is every release
after that. The package gives you /usr/bin/hotserve, a systemd
service running as the hotserve user, a starter config at
/etc/hotserve/Caddyfile, a systemd instance for the hotserve
user that stays up without anyone logging in — the thing your apps
will run under — and, for a box whose config lives in a repository,
the root applier that installs each signed push and the box's key
for secrets.
Supported: Debian 13, on a real virtual machine. The package refuses to install where systemd is older than 257 (Debian 12 and earlier, Ubuntu 24.04): each app's sandbox needs it. It installs on other Debian-based distributions with systemd 257 or newer, but nothing else is tested. On a host that cannot deliver the per-app sandbox — many LXC-based and other container-style servers — hotserve refuses a config that runs apps rather than serving something weaker; liveswap/README.md has what a host has to provide.
The package also depends on libpam-systemd, dbus and
openssh-client, all present on a stock Debian server: liveswap runs
your apps as systemd units under the hotserve user's own service
manager, which needs pam_systemd to start and loginctl to stay
alive without a login, and the box checks each config commit's SSH
signature with ssh-keygen.
Without the package — the raw binary, for other systemd hosts
The hotserve_<version>_linux_<arch>.tar.gz archives on the same page
hold the raw binary (plus LICENSE and a README), for other systemd
hosts you wire up yourself — a NixOS-style distro, say. Prefer the
packages where you can: going this way you take on what the package
does for you, all of it in packaging/ (nfpm.yaml and
postinstall.sh are the whole list): a dedicated hotserve user, the
Type=notify unit hotserve.service, loginctl enable-linger hotserve (with libpam-systemd installed) so the user manager exists
for your apps, the config at /etc/hotserve/Caddyfile, and, for config
pushed from a box repository, the root applier's units
(hotserve-box-apply.path and .service), its directories
(hotserve-box.tmpfiles) and the box's key (hotserve box key, as
root). The sandbox applies here too, so the host
needs systemd 257 or newer and has to deliver both namespaces (many
container and LXC hosts cannot) and seccomp. hotserve checks both from
inside a test unit at start and refuses to start rather than run your
apps without them. One case passes that check: a container host whose
existing seccomp rules leave the kernel no room for the app units'
system-call filter
(liveswap/DESIGN-sandbox.md, "What this
does NOT stop").
systemd is not optional. liveswap runs your apps as transient
units on your user's systemd manager and there is no fallback runner,
so this is not a path onto a non-systemd host. hotserve would still
serve there — Caddy, the cache, penaltybox — but a Caddyfile defining
any app refuses to start. That is also why there is no .apk:
Alpine runs OpenRC, and an Alpine package would install a hotserve
without the feature it exists for.
(A hosted APT repository with automatic updates is on the roadmap.)
Installing needs root; administering does not. Checking a config
(hotserve validate) needs no privilege, and reading the journal —
hotserve's and every app's — needs the adm group. The config changes
only by a signed push from the box's repository
(examples/box), and so do the apps' secrets kept there,
as values encrypted to the box's own key
(docs/secrets.md); hotserve itself runs
unprivileged. One thing reaches wider: adm reads the
whole system journal, not only hotserve's lines, so it is a trust
grant in its own right. The e2e suite administers its box that way.
Your first deploy takes a fresh Debian 13
server to an app that deploys on every push to main, in four steps,
with the box's config in a repository of its own that the box applies
on each signed push.
After the first deploy then covers
changing the config, secrets (whose reference is
Secrets) and the way back, and
Hardening the box covers SSH by key only, the SSH
port shut, and, optionally, an administrator who is not root,
Debian's updates installed on their own, and seven kernel network
modules no app needs refused.
The first two use the three directories below, which the e2e suite
builds and deploys on every change, so what they say works:
- examples/node — a single executable (Node's own build), so nothing is installed on the box. Largest tarball; simplest box. The tutorial's default. Start your own from the template it is published as, hotserve-example-node.
- examples/deno —
deno runwith a Deno installed under/usron the box, and the runtime's permission flags held in the box's Caddyfile, where a compromised build cannot widen them. Its template is hotserve-example-deno. - examples/box — the box's config, kept in a
private repo of its own: a Caddyfile per box (which app, which repo
may deploy it, where the deploy webhook answers, who may sign a
change), applied by the workflow on each signed push to
main; the box checks the signatures and keeps the file it had if the new one does not load. The apps' secrets live there too, encrypted, one file per value (docs/secrets.md). Its template is hotserve-example-box.
Both apps serve on the socket hotserve hands them, answer /health,
migrate before each version starts, stop cleanly, and deploy from
GitHub Actions with no stored secret.
- A server product, distributed like Caddy. Same CLI, same
Caddyfile, same admin API —
hotserve run,hotserve reload,hotserve validateall behave exactly as Caddy's do. The config is a Caddyfile, though: a JSON config (a.jsonfile, or JSON posted to the admin API) is unsupported, because Caddy deletes every"@id"key from JSON before hotserve's checks can see it. - Made for one cheap server. Apps run as systemd units under hotserve's own user manager, reached over unix sockets; no container runtime anywhere. That's why there's deliberately no Docker image.
- Not a cluster. Single-node by design. If you outgrow one server, you've outgrown hotserve — a good problem.
The threat model is DESIGN-threat-model.md; to report something, SECURITY.md. Four properties it rests on:
- The admin API is not on localhost. It listens on a unix socket
rather than TCP: "localhost-only" would include every app you run,
and one SSRF bug in an app could otherwise reconfigure the server.
Apps themselves run unprivileged, with
NoNewPrivileges, and write no core dump (LimitCORE=0), so a crash cannot leave an app's memory on disk. - Every app is sandboxed, with no opt-out. Apps share the
hotserveuser, so each runs in its own systemd sandbox: it sees its own release, itsshared/data, its instance's socket directory (run/<nonce>/), a private/tmp, and the parts of the OS it needs. Nothing else on the host exists in its view — hotserve's keys and every sibling app are absent from its filesystem and invisible in its process table, not merely unreadable. A host that cannot deliver that refuses to start rather than run something weaker. What is shared by design is the network, for outbound calls; nothing hotserve runs listens on a port. Details: liveswap/README.md. - Deploys are authenticated without a shared secret. A deploy, and a config push, carries a short-lived token — an OIDC token from CI, verified against the provider's public keys, or one signed by a local key whose public half the box holds. Nothing an attacker can steal off the box lets them deploy.
- hotserve cannot change its own config. The Caddyfile is the box's policy — which repository may deploy each app, each runtime's permission flags, who may sign the next change — and only root writes it. A push from the box's repository reaches root through hotserve, and root installs the file only if the commit descends from the one the box runs and is signed by a key the running file names. Details: box/DESIGN-box.md.
Per-app sandboxing is shipped and unconditional — see Security above for what an app can reach, liveswap/README.md for the unit's own settings, and DESIGN-threat-model.md for why each one is there. What is still ahead:
- Per-app UIDs. Apps share the unprivileged
hotserveuser; giving each its own would need a small privileged helper, so it stays a later milestone. - A hosted APT repository, with package signing and auto-updates.
Not committed to, but worth naming: a metrics/alerts module alongside liveswap and penaltybox. The first thing it would earn its keep on is alerting when the watchdog is stuck in a restart loop — the watchdog retries a crash or a health failure forever by design (only a release that cannot run at all is refused once), so the loop itself is the signal that a release is broken.
They go in Discussions; issues and pull requests are the maintainer's. See CONTRIBUTING.md.
No local Go toolchain needed — everything runs in Docker:
make test # unit tests, all modules (race + coverage)
make test-integration # real deploys through caddytest, under a real systemd user manager
make e2e # full stack: the liveswap and penaltybox suites against the shipped binary under systemd, restart survival + crash recovery, then signed box pushes
make lint vet tidy # golangci-lint (gofmt-gated), go vet, go mod tidy
make tidy-check # fails if go mod tidy would change a module's go.mod/go.sum; CI runs it on every PR
make vulncheck # govulncheck, all modules (tool dep in go.mod — Dependabot-bumped)
make secretscan # gitleaks secret scan: full history, or the working tree in a git worktree (same image as the CI gate)
make usage # what building this costs (Claude Code, Actions minutes, Copilot) → usage/*.csv + docs/footprint.md; needs gh auth login
make usage-check # usage.py's tests, then fails if docs/footprint.md no longer matches usage/*.csv; CI runs it on every PR
make fuzz # fuzz the untrusted-input surfaces (FUZZTIME=2m per target)
make fuzz-list # what fuzz will run (discovered per module, not listed by hand); CI runs it on every PR
make soak # ~20min leak hunt: deploy/reload churn, goroutine/fd assertions; CI runs it per merge to main and weekly
make build # cross-compile linux amd64/arm64
make package # .deb via nfpm, into dist/
make install-test # install dist/'s .deb under real systemd (Debian 13);
# run `make package` first — dist/ is not rebuilttest-integration, e2e and install-test boot systemd inside a
privileged container, which needs a cgroup-v2 Docker host — Docker
Desktop (macOS/Windows) or Linux with systemd both qualify; the targets
check and say so up front. That is also how liveswap's systemd runner
is developed and tested on a Mac: nothing here needs a Linux VM.
What building hotserve costs — Claude Code sessions, runner minutes, Copilot reviews, in money, kWh and CO2 — is a committed ledger: docs/footprint.md.
The repo is a Go multi-module workspace: liveswap/ and penaltybox/
are lean, independently usable Caddy modules
(xcaddy build --with github.com/smallhoursorg/hotserve/liveswap).
box/ is a module of its own but part of the product only: it imports
liveswap through a local replace, so it builds only from this repo,
and it runs only with the package's applier units
(box/DESIGN-box.md). The root module
builds the product binary from cmd/hotserve, and packaging/ turns
it into the .deb.
Dependabot opens weekly grouped PRs per ecosystem with a 7-day
cooldown (security updates bypass it); patch/minor bumps auto-merge
once the full CI graph is green. Two escape hatches, both declared in
.github/pin-watch.yml and enforced by the
weekly pin-watch workflow:
- Pins. Every
ignoreindependabot.ymlmust have awatchesentry declaring the machine-checkable condition under which the pin is lifted (e.g. "caddy's latest release requires cel-go ≥ 0.29"). The workflow opens an issue when a condition is met, and flags any ignore↔watch drift — a pin can never rot silently. - Alert triage.
alert_dismissalsentries auto-dismiss Dependabot alerts by GHSA id, andcode_scanning_dismissalsentries do the same for CodeQL alerts by rule + file (with anexpectcap so a new flow in the same file is never silently swallowed) — each with a reviewed reason and evidence comment, so triage decisions are versioned instead of buried in the UI. Unlisted alerts stay open and notify as normal. Analert_dismissalsentry may also name the advisory's Go vulndb ID (govulncheck:);make vulncheckthen lets that one ID through, and fails once nothing calls it any more. This needs theDEPENDABOT_ALERTS_TOKENActions secret — a fine-grained PAT with Dependabot alerts: read-write on this repository only — because the ActionsGITHUB_TOKENcannot access the alerts API.
You're being asked to install this on a production server, so
here's exactly what's in the binary and what it drags in. Module counts
are measured from go mod graph (deduplicated by module path); the
"pulls in" column shows what each dependency adds beyond what's
already in the tree above it, because the trees overlap almost
entirely. (Counts exclude the govulncheck tool dependency — a
tool directive is never compiled into the product and never
inherited by importers; it exists in go.mod so Dependabot can bump
the scanner.)
| Dependency | Pulls in (~modules) | hotserve | liveswap | penaltybox | box | Notes |
|---|---|---|---|---|---|---|
| Caddy | ~565 | ✓ | ✓ | ✓ | ✓ | The foundation — hotserve is a Caddy distribution. Commercially sponsored, strong security track record. Nearly the entire dependency tree is Caddy's. |
Souin (cache) |
+24 | ✓ | — | — | — | HTTP cache (RFC 7234). Solo-maintained; the e2e suite exercises it directly so drift is caught in CI. The riskiest dependency here, and still better than an in-house HTTP cache. |
| darkweak/storages/otter | +6 | ✓ | — | — | — | In-memory storage backend for Souin, wrapping maypok86/otter. |
| zap | 0 (already in Caddy's tree) | ✓ | ✓ | ✓ | ✓ | Caddy's module logging API is zap; not optional for a Caddy module. |
| go-humanize | 0 (already in Caddy's tree) | ✓ | ✓ | — | — | A few formatting helpers in liveswap. |
go-systemd (dbus) + godbus |
+1 | ✓ | ✓ | — | — | liveswap's runner: apps are transient units created over systemd's D-Bus API on the hotserve user's own manager (no polkit, no root). go-systemd was already in the workspace graph; godbus is its one dependency, pure Go. |
| go-oidc + go-jose | 0 (already in Caddy's tree) | ✓ | ✓ | — | — | Deploy authentication (deploy_trust): OIDC discovery/JWKS verification and JWT signing/verification. Both were already transitive dependencies of Caddy; liveswap now requires them directly, and box's pushes use the same verifier through liveswap. Vetted and widely used — the deliberate alternative to hand-rolling JWT crypto. |
age (filippo.io/age) |
+3 | ✓ | — | — | ✓ | Secrets: each value in the box repository is an age file encrypted to the box's own key, which root decrypts on the box into the app's env file. Chosen over SOPS, whose library measured 80 more modules in a binary root runs (box/DESIGN-box.md, "Decisions and rejected alternatives"). |
A ✓ in the liveswap, penaltybox and box columns means that module's own code requires the dependency. A "—" there is not "absent from that module's graph" — the rows marked "already in Caddy's tree" arrive via Caddy either way. The hotserve column is ✓ throughout: the product binary contains every row.
Build and CI tooling never ships to users and is pinned by image tag
in docker-compose.yml: golang (toolchain), golangci-lint,
gitleaks (secret scan), nfpm (deb packaging) and curl (the e2e
runner). GitHub Actions are pinned to commit SHAs.
What keeps this honest: govulncheck gates every PR and runs weekly
against the fresh vulnerability database (reachable-code analysis, all
modules; the only exceptions are the reviewed govulncheck: entries
in pin-watch.yml, each with its evidence), every release is blocked until the full test matrix passes —
including installing the actual .deb under systemd on Debian 13, on
both architectures — and any dependency bump has to survive all of the
above before it merges.
Apache-2.0. hotserve is powered by Caddy, Souin, Otter, and age.