Skip to content

feat(deploy): Hetzner deploy unit for api.nesy-prover.dev - #330

Merged
hyperpolymath merged 4 commits into
mainfrom
feat/hetzner-deploy-unit
Jul 28, 2026
Merged

feat(deploy): Hetzner deploy unit for api.nesy-prover.dev#330
hyperpolymath merged 4 commits into
mainfrom
feat/hetzner-deploy-unit

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Adds deploy/hetzner/ — the complete unit for running the public ECHIDNA API on a single server behind Caddy, plus an operator runbook.

echidna (ghcr.io/hyperpolymath/echidna:latest, server --port 8081) and caddy (built on-box with mholt/caddy-ratelimit) run as rootless podman systemd quadlets on a shared network. Caddy terminates TLS for api.nesy-prover.dev and is the only thing that can reach the API.

Caging an unauthenticated prover service

The API has no authentication and spawns prover binaries on user-supplied input with no per-request timeout, so the deployment constrains it: no published host port, 30 req/min/IP rate limit, 1 MB request-body cap, --memory=1500m --pids-limit=512, tmpfs /tmp, NoNewPrivileges, rootless. (A server-side prover timeout belongs in the code — worth a follow-up issue.)

Review fixes already applied

  • runbook ran scp before the target directories existed — a verbatim run failed on the first copy
  • the auto-update rollback claim was not true as configured: an unhealthy-but-running container satisfies systemd, so nothing failed and nothing rolled back. HealthOnFailure=kill now fails the unit on an unhealthy container, which is what makes both Restart=always and auto-update's rollback fire
  • manual rollback was silently undone by the next podman-auto-update.timer run re-pulling :latest — the procedure now stops the timer first and documents pinned mode as the durable exit
  • the podman >= 4.4 floor was a guess these units do not satisfy. It is replaced by a measurement: preflight runs the quadlet generator in dry-run mode on the target host, proving every key is understood regardless of version

Verification

  • quadlet -dryrun generates all three units with no warnings; the resulting ExecStart carries --health-on-failure kill, tmpfs, no-new-privileges, the autoupdate label, and both limits
  • the Caddy image builds with podman, and caddy validate accepts the Caddyfile with http.handlers.rate_limit present
  • ghcr.io/hyperpolymath/echidna:latest confirmed anonymously pullable — no registry credentials needed on the server
  • asciidoctor renders the runbook with no warnings

Deployment itself is not part of this PR: it needs the server's SSH details and a Cloudflare token for the api A record.

hyperpolymath and others added 2 commits July 28, 2026 15:42
Complete single-box deployment for the public API: rootless podman
systemd quadlets (echidna API + Caddy TLS proxy), a Caddy image built
on-host with mholt/caddy-ratelimit, and an operator runbook.

The API is unauthenticated and spawns prover binaries on user input,
so it is caged: no published host port (Caddy-only reachability over
the quadlet network), 30 req/min/IP rate limit, 1 MB body cap,
memory/pids limits, tmpfs /tmp, NoNewPrivileges. Updates roll out via
podman-auto-update with healthcheck-gated rollback.

Verified locally: quadlet -dryrun generates all three units with zero
warnings and correct dependency ordering; the Caddy image builds with
podman and 'caddy validate' accepts the Caddyfile with
http.handlers.rate_limit present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ating

Adversarial review of this deploy unit found operator-facing defects:

- Section 2 ran scp before the target directories existed, so a
  verbatim run failed on the first copy. Create /opt/echidna (root
  section) and ~/.config/containers/systemd (deploy user) first.
- The auto-update rollback claim was not true as configured: an
  unhealthy-but-running container satisfies systemd, so nothing would
  have failed and nothing would have rolled back. Add
  HealthOnFailure=kill to echidna.container — an unhealthy container
  now fails the unit, which is what makes both Restart=always and
  auto-update's rollback fire — and describe the mechanism honestly.
- The manual rollback procedure was silently undone by the next
  podman-auto-update.timer run, which re-pulls registry :latest. Stop
  the timer first, and document pinned mode as the durable exit.
- The podman >= 4.4 floor was a guess that several keys in these units
  (HealthOnFailure= in particular) would not satisfy. Replace the
  guess with a measurement: the preflight now runs the quadlet
  generator in dry-run mode on the target host, which proves every key
  is understood regardless of version.
- Clarify that the 1500m container cap sits deliberately below the
  2 GB host requirement to leave headroom, and note that the
  plaintext isolation probe is asserting a closed port on purpose.

Convert the indentation-based literal blocks to delimited source
blocks so the runbook renders predictably.

Verified: asciidoctor renders with no warnings; quadlet -dryrun
generates all three units cleanly with --health-on-failure kill in the
resulting ExecStart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread deploy/hetzner/echidna.container
@gitar-bot

gitar-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

Note

Automatic reviews are paused because your trial's included automatic processing has been used for this period. Upgrade now, or comment "Gitar review" to run a review anytime.
Learn more

Code Review ✅ Approved 1 resolved / 1 findings

Adds the Hetzner deployment configuration and operator runbook for the public ECHIDNA API, but the healthcheck uses --version instead of probing the API endpoint, preventing the automated rollback mechanism from detecting a broken server.

✅ 1 resolved
Bug: Healthcheck --version can't detect a broken API server

📄 deploy/hetzner/echidna.container:9 📄 deploy/hetzner/echidna.container:26-31 📄 deploy/hetzner/README.adoc:208-214
The unit's whole rollback safety net (documented in README §5 and the header comment) depends on an unhealthy container failing the systemd unit so Restart=always and podman-auto-update roll back. But HealthCmd=/app/bin/echidna --version (confirmed at src/rust/main.rs:30, Containerfile:105) exits 0 immediately without touching the HTTP listener — it only proves the binary is present. A new :latest that starts but fails to bind, panics after startup, or serves 500s will report healthy and will NOT trigger rollback, defeating the stated protection. Replace it with a real endpoint probe (e.g. HealthCmd hitting http://localhost:8081/api/health, adding a small client tool or wget to the image since it ships no curl), or at minimum document that the check only guards against a missing/corrupt binary.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@hyperpolymath
hyperpolymath merged commit 3e3f81a into main Jul 28, 2026
@hyperpolymath
hyperpolymath deleted the feat/hetzner-deploy-unit branch July 28, 2026 17:34
hyperpolymath added a commit that referenced this pull request Jul 29, 2026
Documents where ECHIDNA actually runs — the map that was missing after
the site and deploy work landed in #329/#330.

## What's here

**`docs/HOSTING.md`** (humans) — the three public surfaces and how each
is built:

| Surface | Host |
|---|---|
| `nesy-prover.dev` — website | Cloudflare Pages, built by `pages.yml`
(Ddraig SSG) |
| `api.nesy-prover.dev` — HTTP API | Hetzner, rootless podman quadlets
behind Caddy |
| `sudo@nesy-prover.dev` — contact | Cloudflare Email Routing forwarder
|

It also captures the two site constraints that bite when editing: the
SSG **hard-fails the build** on any a11y violation (more than one `h1`,
a skipped heading level, a missing `alt`), and its `copyTree` is
**text-only and corrupts binaries** — which is why the playground has a
separate `cp` step.

**`.machine_readable/deployment.a2ml`** (machines) — the same topology
as structured data, including the API's limits (`30/min/ip`, 1 MB body,
`--memory=1500m --pids-limit=512`, no published host port) and an
explicit risk block: the API has no authentication and spawns prover
binaries on user input, rate limiting bounds request *count* rather than
the cost of one request, and a per-request prover timeout is
outstanding.

## Honest about what is broken

Both files record the two live blockers rather than describing an
aspirational happy path:

1. **Repository-wide Actions `startup_failure`** — every workflow,
including `pages.yml`. A minimal probe workflow containing one
SHA-pinned `actions/checkout` fails identically, so this is *not* a
workflow-file fault.
2. **The apex has no origin** — `nesy-prover.dev` resolves to
Cloudflare, the TLS handshake completes, and the request then hangs. The
DNS records are likely already right; the Pages project behind them is
what is missing.

Both also carry the diagnostic that keeps costing time here: **a
workflow that fails at startup registers no check run**, so `gh pr
checks` reports success while every real gate is dead — and a workflow
listed by *path* instead of *name* has never been parsed.

## Stale doc neutralised

`docs/deployment/DEPLOYMENT_GUIDE.md` becomes a superseded stub. It
described a 2025-11 v0.1.0 deployment whose next step was "deploy to
GitLab", referenced an unrelated `zotero-voyant-export` migration, and
quoted a fixed prover count that the canonical-reference policy forbids
in prose. Kept as a stub so existing links don't dead-end.

## Verification

`.githooks/validate-a2ml.sh` passes (exit 0) with the new manifest
present — the same validator the dogfood gate runs.

Note that CI cannot verify this PR: see blocker 1 above.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant