G
YouTube TikTok Games
Skip to content

Latest commit

 

History

History

hetzner

Deploying the ECHIDNA API to Hetzner (api.nesy-prover.dev)

This directory is the complete deploy unit for running the public ECHIDNA API on a single Hetzner server behind Caddy (auto-TLS via Let’s Encrypt), orchestrated with podman systemd quadlets.

Table 1. What runs where

echidna

ghcr.io/hyperpolymath/echidna:latest, server --port 8081 --host 0.0.0.0 --cors. No published host port — reachable only from Caddy over the echidna-net podman network.

caddy

localhost/caddy-rl:2 (built on-box from Containerfile.caddy), ports 80/443, terminates TLS for api.nesy-prover.dev, rate-limits (30 req/min/IP), caps request bodies at 1 MB.

Decision record

  • Quadlets over podman-compose: systemd-native restart-on-boot with no extra daemon, unit-level dependency ordering, and first-class AutoUpdate=registry integration with podman-auto-update.timer.

  • Rootless: the API executes user-supplied prover input; run both units as an unprivileged deploy user with linger.

  • Caddy over svalinn: the estate’s svalinn/vordr stack is not yet CI-proven; Caddy is the low-risk choice for a public endpoint. The container/compose.toml selur stack remains for local development.

  • No API auth at launch: the service is deliberately public but caged (rate limit, body cap, memory/pids limits, tmpfs, unpublished port). If abuse appears, add a bearer-token matcher in the Caddyfile for POST routes. A server-side per-request prover timeout is tracked as a follow-up issue in the main repo. Superseded 2026-11-05: see Containment stopgap (owner ruling 2026-11-05).

Containment stopgap (owner ruling 2026-11-05)

Until bearer authentication lands on every execution route, this host serves read-only routes only, and it no longer rolls out new images by itself.

  • The Caddyfile forwards only GET/HEAD on /api/health and /api/provers. Every other request is answered 403 by Caddy and never reaches the container. That includes /api/prove, /api/verify*, /api/session/, /api/agent/ and /api/search.

  • echidna.container drops AutoUpdate=registry and sets Pull=never. The unit runs the image already on the host, so a newly published :latest is not deployed. This freezes the running image. It does not verify it.

Apply it on the host as the deploy user with stopgap-apply.sh, from a copy of this directory:

ssh deploy@<SERVER_IP> 'mkdir -p /opt/echidna/stopgap'
scp Caddyfile echidna.container stopgap-apply.sh deploy@<SERVER_IP>:/opt/echidna/stopgap/
ssh deploy@<SERVER_IP> 'bash /opt/echidna/stopgap/stopgap-apply.sh apply /opt/echidna/stopgap'

The script fails closed. It stops before restarting anything when a step cannot be confirmed, and it ends by printing stopgap: PASS. In order, it:

  1. Checks that echidna.container is the stopgap version. It reads the directives as systemd does: indentation and spaces around = are ignored, and so are # and ; comment lines. The [Container] section must set exactly one Image=ghcr.io/hyperpolymath/echidna:latest and exactly one Pull=never. No AutoUpdate= may appear in any section, and no io.containers.autoupdate label either.

  2. Checks that the Caddyfile has the stopgap shape. Comments are ignored. There must be exactly one @public matcher, holding exactly method GET HEAD and path /api/health /api/provers, and no other named matcher. There must be exactly one reverse_proxy, and it must be the only thing inside handle @public. Exactly one catch-all handle, next to it, must hold only respond …​ 403. No other handle is allowed, and neither is import, invoke, handle_path, rewrite, uri, php_fastcgi or forward_auth. method is allowed only inside @public. This is a check of the text, not of caddy adapt output: the proxy image carries the rate_limit plugin, which a stock caddy does not know, and the host has no caddy outside that image.

  3. Detects the install layout, and refuses unless exactly one is present. A quadlet host (§2) runs echidna.service and caddy.service; an appendix host runs container-echidna and container-caddy.

  4. Disables podman-auto-update.timer. That does not end a run the timer has already started, so it also stops podman-auto-update.service. It then refuses unless systemctl --user is-active reports that service inactive or failed.

  5. Records the image ID the echidna container is running, in ~/.local/state/echidna/stopgap-running-image. If the local :latest tag has moved since the container started, it points the tag back at the running image, because a restart under Pull=never starts whatever :latest resolves to.

  6. Installs the Caddyfile into /opt/echidna/. On a quadlet host it also installs echidna.container, reloads systemd and restarts echidna and caddy. On an appendix host it restarts only container-caddy: those containers carry no auto-update label, and podman generate systemd --new units do not pull an image that is already present.

  7. Verifies that the container still runs the recorded image, that the timer is disabled and no auto-update run is active, and that GET /api/health answers 200. It then sends each of these and requires 403 from every one: POST to /api/prove, /api/verify, /api/verify_raw, /api/verify_parallel, /api/suggest, /api/session/create and /api/agent/plan, GET /api/search, POST /api/health, and GET on a route the server does not have. Any other answer, a 429 from the rate limiter included, fails the run. One run sends at least 11 requests, and more while it waits for /api/health. The limit is 30 a minute for each client, so wait a minute before running verify again after a failure.

To check the host again later, from any shell:

bash /opt/echidna/stopgap/stopgap-apply.sh verify

Do not proceed if it prints a FAIL line. The script’s behaviour is tested with stub commands in tests/deploy/stopgap-apply.test.sh. Neither the script nor its test has been run against the live host.

The stopgap ends in the change that enables authentication. That change re-pins Image= to the signed image by digest and removes the @public matcher. Do not re-enable podman-auto-update.timer before then.

0. Preflight (read-only, do first)

ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version'

Gates — stop and reassess if any fail:

  • Ports 80/443 unbound (ss -tlnp shows no listener). If something else owns them, this plan does not apply as-is.

  • At least 2 GB RAM. The API container is capped at 1500 MB (PodmanArgs=--memory=1500m), deliberately below the host total so Caddy and the OS keep headroom; raise both together if you want more. The :full image (Isabelle/Agda/Julia) needs >= 4 GB and is not deployed here.

  • Quadlet keys are all understood by the installed podman. Do not trust a version number for this — measure it. Copy the three unit files to the server and run the generator in dry-run mode:

    scp echidna.network echidna.container caddy.container <user>@<SERVER_IP>:/tmp/qcheck/
    ssh <user>@<SERVER_IP> 'QUADLET_UNIT_DIRS=/tmp/qcheck /usr/libexec/podman/quadlet -dryrun'

    It must print generated [Service] sections for all three units with no warnings. These units were authored and dry-run verified against podman 5.4; podman 5.x is recommended and 4.x is likely to reject at least one key (HealthOnFailure= in particular). If the dry-run complains, use the Debian-12 fallback in the appendix instead.

  • Image pullable anonymously:

    podman manifest inspect ghcr.io/hyperpolymath/echidna:latest

    On 401: make the GHCR package public (GitHub → Packages → echidna → Package settings → visibility) or use a read-only PAT with podman login ghcr.io as the deploy user.

1. Server preparation (as root)

apt update && apt upgrade -y
apt install -y podman unattended-upgrades ufw
dpkg-reconfigure -f noninteractive unattended-upgrades

# Firewall: SSH + HTTP(S) only
ufw default deny incoming
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
ufw enable

# Unprivileged deploy user with lingering services
useradd -m -s /bin/bash deploy
loginctl enable-linger deploy

# Let rootless podman bind 80/443
echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/50-unprivileged-ports.conf
sysctl --system

# Destination for the Caddyfile and the Caddy build context
mkdir -p /opt/echidna && chown deploy:deploy /opt/echidna

2. Install the deploy unit

Create the quadlet directory before copying anything into it — as the deploy user on the server:

mkdir -p ~/.config/containers/systemd

Then, from your workstation in this directory:

scp Caddyfile Containerfile.caddy deploy@<SERVER_IP>:/opt/echidna/
scp echidna.network echidna.container caddy.container \
    deploy@<SERVER_IP>:~/.config/containers/systemd/

Back on the server, as deploy:

cd /opt/echidna
podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
podman pull ghcr.io/hyperpolymath/echidna:latest
podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
# ^ record this digest in env.example / your notes for rollback

systemctl --user daemon-reload
systemctl --user list-unit-files | grep -E 'echidna|caddy'

3. DNS (before starting Caddy)

Create in the Cloudflare zone for nesy-prover.dev:

A  api  <SERVER_IP>  proxied=false (grey cloud), TTL 300

Grey cloud is required at launch so Caddy’s Let’s Encrypt HTTP-01 challenge reaches the server directly. (Optional later hardening: orange-cloud + a Cloudflare Origin CA cert in the Caddyfile.)

Wait until dig +short api.nesy-prover.dev @1.1.1.1 returns the server IP before the first Caddy start — ACME then succeeds first try.

4. Bring-up and verification

systemctl --user start echidna caddy
podman ps                                   # both Up, echidna (healthy)
journalctl --user -u caddy -f               # watch certificate issuance

End-to-end, from anywhere:

curl -s https://api.nesy-prover.dev/api/health        # {"status":"ok",...}
curl -s https://api.nesy-prover.dev/api/provers | head
# While the containment stopgap is in force, execution routes answer 403
# from Caddy. That is the correct result, not a failed deployment:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.nesy-prover.dev/api/verify \
  -H 'content-type: application/json' \
  -d '{"prover":"Z3","content":"(assert (forall ((x Int)) (= (+ x 0) x)))(check-sat)"}'   # expect 403

Controls:

# Rate limit: burst 40 requests, expect HTTP 429 after ~30
for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code} ' \
  https://api.nesy-prover.dev/api/health; done; echo

# Headers: HSTS present, Server header absent
curl -sI https://api.nesy-prover.dev/api/health

# Isolation: the API port must NOT be reachable directly. This probe is
# deliberately plaintext against the raw port — it is asserting that
# nothing answers there, and must fail.
curl -m 5 http://<SERVER_IP>:8081/api/health && echo "REACHABLE - FIX THIS" || echo "unreachable (correct)"

Reboot survival (validates linger + [Install] + Restart in one shot):

sudo reboot
# after it returns:
podman ps && curl -s https://api.nesy-prover.dev/api/health

5. Updates and rollback

Note
Unattended rollout is disabled while the Containment stopgap (owner ruling 2026-11-05) is in force. The commands below apply only to a tag-based Image= with AutoUpdate=registry, because podman auto-update acts only on containers that carry that label. A digest-pinned image, which is what the change ending the stopgap introduces, changes only through a PR that bumps the digest.

ghcr-publish.yml pushes a new :latest on every GitHub release. Enable unattended rollout:

systemctl --user enable --now podman-auto-update.timer
podman auto-update --dry-run     # see what would change
podman auto-update               # roll out immediately

How the safety net actually works: podman auto-update pulls the new image and restarts the unit. HealthOnFailure=kill in echidna.container means a container that goes unhealthy is killed, which fails the systemd unit — and auto-update rolls the unit back to the image it was running before when the restarted unit does not come up healthy. Without that key an unhealthy-but-running container would satisfy systemd and no rollback would occur, so do not remove it.

Manual rollback to a recorded digest. Stop the timer first — otherwise its next run pulls registry :latest again and silently undoes the rollback:

systemctl --user disable --now podman-auto-update.timer
podman pull ghcr.io/hyperpolymath/echidna@<DIGEST>
podman tag  ghcr.io/hyperpolymath/echidna@<DIGEST> ghcr.io/hyperpolymath/echidna:latest
systemctl --user restart echidna

Re-enabling the timer does nothing on its own: podman auto-update acts only on containers labelled by AutoUpdate=registry, and the stopgap unit removes that key. Restoring unattended rollout therefore means putting AutoUpdate=registry back in echidna.container (and dropping Pull=never), reloading systemd, and only then enabling the timer. That ends the containment stopgap, so it belongs to the change that lands authentication, not to an operator step. The intended end state is pinned mode: Image= set to the signed image’s digest, no AutoUpdate, and updates by PR.

Appendix: fallback for podman without quadlet support

podman network create echidna-net
podman create --name echidna --network echidna-net \
  --memory=1500m --pids-limit=512 --tmpfs /tmp:rw,size=256m \
  --security-opt no-new-privileges \
  --health-cmd '/app/bin/echidna --version' --health-interval 30s \
  --health-on-failure kill \
  ghcr.io/hyperpolymath/echidna:latest server --port 8081 --host 0.0.0.0 --cors
podman create --name caddy --network echidna-net \
  -p 80:80 -p 443:443 \
  -v /opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro \
  -v caddy-data:/data -v caddy-config:/config \
  localhost/caddy-rl:2
podman generate systemd --new --files --name echidna
podman generate systemd --new --files --name caddy
mkdir -p ~/.config/systemd/user && mv container-*.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now container-echidna container-caddy