hotserve
The Hot Sauce server. One binary that is your reverse proxy, your
deploy pipeline, your rate limiter, and your page cache — 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 supervised child processes — Node.js, Go, anything that listens on a port. |
| 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. |
| 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. |
Install
Grab the .deb or .apk for your architecture from
releases:
sudo apt install ./hotserve_*_amd64.deb
sudo systemctl enable --now hotserve
That gives you /usr/bin/hotserve, a systemd service running as the
hotserve user, and a starter config at /etc/hotserve/Caddyfile.
Raw binaries are on the releases page too. (Hosted APT/APK
repositories with automatic updates are on the roadmap.)
Quickstart: deploy an app with zero downtime
/etc/hotserve/Caddyfile:
{
liveswap {
webhook_secret {env.LIVESWAP_SECRET}
artifact_allowlist github.com/your-org/ # required: pin artifact origins
app myapp {
command node server.js # runs in the release dir, PORT injected
pre_start node migrate.js # failure aborts the deploy
env_file /etc/hotserve/myapp.env
}
}
cache {
ttl 60s
otter
}
}
myapp.example.com {
hint_penaltybox # rate limiting from app hint headers
cache # page cache
reverse_proxy {
dynamic liveswap myapp # traffic follows the live version
}
}
deploy.example.com {
liveswap_webhook
}
Then from CI (GitHub Actions, GitLab CI, anything that can curl):
curl --fail -X POST -H "Authorization: Bearer $SECRET" \
-d '{"url":"https://…/myapp.tar.gz","version":"v1.4.2"}' \
https://deploy.example.com/myapp
hotserve downloads the artifact, runs your migration, starts the new
version on a private port, health-checks it until it has been solidly
up, atomically moves traffic over, and gracefully stops the old one.
If anything fails, the old version never stops serving and CI goes
red. Rollback is re-POSTing the previous version. Full details, CI
snippets, and every option: liveswap/README.md.
What hotserve is (and isn't)
- A server product, distributed like Caddy. Same CLI, same
Caddyfile, same admin API —
hotserve run, hotserve reload,
hotserve validate all behave exactly as Caddy's do.
- Made for one cheap server. Apps run as supervised child
processes of hotserve on localhost ports; 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.
- One box, one trust domain. The systemd sandbox protects the
system from your apps, and the admin API lives on a unix socket
rather than TCP — otherwise "localhost-only" would include every app
you run, and one SSRF bug in an app could reconfigure the server.
But apps currently run as one shared user, so hotserve does not
protect your apps from each other: run only workloads you trust
together, or reach for containers. Per-app sandboxing (bubblewrap,
Flatpak-style) is designed and next up —
liveswap/DESIGN-sandbox.md.
Roadmap
- [High priority] Per-app sandboxing / isolation by default
(bubblewrap: mount + PID + user namespaces, no containers) —
designed, see
liveswap/DESIGN-sandbox.md. The
concrete gap driving the priority: because every app currently runs
as the shared
hotserve UID, a compromised app can read the
supervisor's own environment via /proc/<hotserve-pid>/environ
(which holds LIVESWAP_SECRET and ACME DNS tokens), connect to the
admin unix socket, and read the TLS private keys and sibling apps'
files. Scrubbing the child environment (done) stops direct
inheritance but not the /proc and filesystem routes — only a real
isolation boundary (bubblewrap's PID + mount namespaces hide
/proc/<supervisor> and the socket/key paths entirely; a per-app
UID via the future systemd runner is the alternative) closes it.
This is the next security milestone, ahead of the items below.
- Hosted APT/APK repositories with package signing and auto-updates
- Continuous watchdog: restart a running app on crash or sustained
health failure (today apps are relaunched at boot and replaced on
deploy)
- A metrics/alerts module to sit alongside liveswap and penaltybox
Development
No local Go toolchain needed — everything runs in Docker:
make test # unit tests, all modules (race + coverage)
make test-integration # real deploys through caddytest
make e2e # full stack: both module suites against the shipped binary, then crash recovery
make lint vet tidy
make vulncheck # govulncheck, all modules (tool dep in go.mod — Dependabot-bumped)
make secretscan # gitleaks full-history secret scan (same image as the CI gate)
make fuzz # fuzz the untrusted-input surfaces (FUZZTIME=2m per target)
make soak # ~20min leak hunt: deploy/reload churn, goroutine/fd assertions
make build # cross-compile linux amd64/arm64
make package # .deb/.apk via nfpm
make install-test # install the .deb under real systemd (DISTRO=debian:12 etc.)
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),
and the root module builds the product binary from cmd/hotserve.
Dependency policy
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
ignore in dependabot.yml must have a watches
entry 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_dismissals entries auto-dismiss Dependabot
alerts by GHSA id, and code_scanning_dismissals entries do the same
for CodeQL alerts by rule + file (with an expect cap 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. This needs the
DEPENDABOT_ALERTS_TOKEN Actions secret — a fine-grained PAT with
Dependabot alerts: read-write on this repository only — because
the Actions GITHUB_TOKEN cannot access the alerts API.
Dependencies
You're being asked to run this as root 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 |
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. |
The liveswap and penaltybox columns are what a standalone
xcaddy build --with ... of that module pulls in; the hotserve binary
contains everything.
Build and CI tooling never ships to users and is pinned by image tag
in docker-compose.yml: golang (toolchain), golangci-lint,
nfpm (deb/apk packaging), curl (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), every release is blocked until the full test matrix passes —
including installing the actual .deb under systemd on Debian and
Ubuntu — and any dependency bump has to survive all of the above
before it merges.
License
Apache-2.0. hotserve is powered by Caddy,
Souin, and
Otter.