Skip to content

Commit 3e3f81a

Browse files
feat(deploy): Hetzner deploy unit for api.nesy-prover.dev (#330)
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.
2 parents 484a998 + 99f274e commit 3e3f81a

7 files changed

Lines changed: 412 additions & 0 deletions

File tree

deploy/hetzner/Caddyfile

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# TLS-terminating reverse proxy for the public ECHIDNA API.
5+
# Runs in the localhost/caddy-rl:2 image (see Containerfile.caddy);
6+
# the rate_limit directive is provided by mholt/caddy-ratelimit.
7+
8+
{
9+
email j.d.a.jewell@open.ac.uk
10+
order rate_limit before reverse_proxy
11+
}
12+
13+
api.nesy-prover.dev {
14+
encode zstd gzip
15+
16+
# The API executes prover input; cap request size and per-client
17+
# rate before anything reaches it.
18+
request_body {
19+
max_size 1MB
20+
}
21+
rate_limit {
22+
zone api {
23+
key {remote_host}
24+
events 30
25+
window 1m
26+
}
27+
}
28+
29+
header {
30+
Strict-Transport-Security "max-age=31536000"
31+
X-Content-Type-Options nosniff
32+
X-Frame-Options DENY
33+
Referrer-Policy no-referrer
34+
-Server
35+
}
36+
37+
# `echidna` resolves on the shared podman network (echidna.network);
38+
# the API container publishes no host port.
39+
reverse_proxy echidna:8081 {
40+
transport http {
41+
response_header_timeout 120s
42+
}
43+
}
44+
}

deploy/hetzner/Containerfile.caddy

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Caddy with the mholt/caddy-ratelimit plugin (stock Caddy has no rate
5+
# limiting). Built on the target host with podman — never published:
6+
#
7+
# podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
8+
#
9+
# RSR-H15: podman + Containerfile, not Docker.
10+
11+
FROM docker.io/library/caddy:2-builder AS builder
12+
RUN xcaddy build --with github.com/mholt/caddy-ratelimit
13+
14+
FROM docker.io/library/caddy:2
15+
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

deploy/hetzner/README.adoc

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
= Deploying the ECHIDNA API to Hetzner (api.nesy-prover.dev)
4+
:toc:
5+
:toclevels: 2
6+
7+
This directory is the complete deploy unit for running the public
8+
ECHIDNA API on a single Hetzner server behind Caddy (auto-TLS via
9+
Let's Encrypt), orchestrated with podman systemd quadlets.
10+
11+
.What runs where
12+
[cols="1,3"]
13+
|===
14+
| `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.
15+
| `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.
16+
|===
17+
18+
== Decision record
19+
20+
* *Quadlets over podman-compose*: systemd-native restart-on-boot with no
21+
extra daemon, unit-level dependency ordering, and first-class
22+
`AutoUpdate=registry` integration with `podman-auto-update.timer`.
23+
* *Rootless*: the API executes user-supplied prover input; run both
24+
units as an unprivileged `deploy` user with linger.
25+
* *Caddy over svalinn*: the estate's svalinn/vordr stack is not yet
26+
CI-proven; Caddy is the low-risk choice for a public endpoint. The
27+
`container/compose.toml` selur stack remains for local development.
28+
* *No API auth at launch*: the service is deliberately public but caged
29+
(rate limit, body cap, memory/pids limits, tmpfs, unpublished port).
30+
If abuse appears, add a bearer-token matcher in the Caddyfile for
31+
POST routes. A server-side per-request prover timeout is tracked as a
32+
follow-up issue in the main repo.
33+
34+
== 0. Preflight (read-only, do first)
35+
36+
[source,console]
37+
----
38+
ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version'
39+
----
40+
41+
Gates — stop and reassess if any fail:
42+
43+
* Ports 80/443 unbound (`ss -tlnp` shows no listener). If something
44+
else owns them, this plan does not apply as-is.
45+
* At least 2 GB RAM. The API container is capped at 1500 MB
46+
(`PodmanArgs=--memory=1500m`), deliberately below the host total so
47+
Caddy and the OS keep headroom; raise both together if you want more.
48+
The `:full` image (Isabelle/Agda/Julia) needs >= 4 GB and is not
49+
deployed here.
50+
* *Quadlet keys are all understood by the installed podman.* Do not
51+
trust a version number for this — measure it. Copy the three unit
52+
files to the server and run the generator in dry-run mode:
53+
+
54+
[source,console]
55+
----
56+
scp echidna.network echidna.container caddy.container <user>@<SERVER_IP>:/tmp/qcheck/
57+
ssh <user>@<SERVER_IP> 'QUADLET_UNIT_DIRS=/tmp/qcheck /usr/libexec/podman/quadlet -dryrun'
58+
----
59+
+
60+
It must print generated `[Service]` sections for all three units with
61+
no warnings. These units were authored and dry-run verified against podman
62+
5.4; podman 5.x is recommended and 4.x is likely to reject at least
63+
one key (`HealthOnFailure=` in particular). If the dry-run complains,
64+
use the Debian-12 fallback in the appendix instead.
65+
* Image pullable anonymously:
66+
+
67+
[source,console]
68+
----
69+
podman manifest inspect ghcr.io/hyperpolymath/echidna:latest
70+
----
71+
+
72+
On 401: make the GHCR package public (GitHub → Packages → echidna →
73+
Package settings → visibility) or use a read-only PAT with
74+
`podman login ghcr.io` as the deploy user.
75+
76+
== 1. Server preparation (as root)
77+
78+
[source,console]
79+
----
80+
apt update && apt upgrade -y
81+
apt install -y podman unattended-upgrades ufw
82+
dpkg-reconfigure -f noninteractive unattended-upgrades
83+
84+
# Firewall: SSH + HTTP(S) only
85+
ufw default deny incoming
86+
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
87+
ufw enable
88+
89+
# Unprivileged deploy user with lingering services
90+
useradd -m -s /bin/bash deploy
91+
loginctl enable-linger deploy
92+
93+
# Let rootless podman bind 80/443
94+
echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/50-unprivileged-ports.conf
95+
sysctl --system
96+
97+
# Destination for the Caddyfile and the Caddy build context
98+
mkdir -p /opt/echidna && chown deploy:deploy /opt/echidna
99+
----
100+
101+
== 2. Install the deploy unit
102+
103+
Create the quadlet directory *before* copying anything into it — as
104+
the deploy user on the server:
105+
106+
[source,console]
107+
----
108+
mkdir -p ~/.config/containers/systemd
109+
----
110+
111+
Then, from your workstation in this directory:
112+
113+
[source,console]
114+
----
115+
scp Caddyfile Containerfile.caddy deploy@<SERVER_IP>:/opt/echidna/
116+
scp echidna.network echidna.container caddy.container \
117+
deploy@<SERVER_IP>:~/.config/containers/systemd/
118+
----
119+
120+
Back on the server, as deploy:
121+
122+
[source,console]
123+
----
124+
cd /opt/echidna
125+
podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
126+
podman pull ghcr.io/hyperpolymath/echidna:latest
127+
podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
128+
# ^ record this digest in env.example / your notes for rollback
129+
130+
systemctl --user daemon-reload
131+
systemctl --user list-unit-files | grep -E 'echidna|caddy'
132+
----
133+
134+
== 3. DNS (before starting Caddy)
135+
136+
Create in the Cloudflare zone for nesy-prover.dev:
137+
138+
[source]
139+
----
140+
A api <SERVER_IP> proxied=false (grey cloud), TTL 300
141+
----
142+
143+
Grey cloud is required at launch so Caddy's Let's Encrypt HTTP-01
144+
challenge reaches the server directly. (Optional later hardening:
145+
orange-cloud + a Cloudflare Origin CA cert in the Caddyfile.)
146+
147+
Wait until `dig +short api.nesy-prover.dev @1.1.1.1` returns the
148+
server IP before the first Caddy start — ACME then succeeds first try.
149+
150+
== 4. Bring-up and verification
151+
152+
[source,console]
153+
----
154+
systemctl --user start echidna caddy
155+
podman ps # both Up, echidna (healthy)
156+
journalctl --user -u caddy -f # watch certificate issuance
157+
----
158+
159+
End-to-end, from anywhere:
160+
161+
[source,console]
162+
----
163+
curl -s https://api.nesy-prover.dev/api/health # {"status":"ok",...}
164+
curl -s https://api.nesy-prover.dev/api/provers | head
165+
curl -s -X POST https://api.nesy-prover.dev/api/verify \
166+
-H 'content-type: application/json' \
167+
-d '{"prover":"Z3","content":"(assert (forall ((x Int)) (= (+ x 0) x)))(check-sat)"}'
168+
----
169+
170+
Controls:
171+
172+
[source,console]
173+
----
174+
# Rate limit: burst 40 requests, expect HTTP 429 after ~30
175+
for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code} ' \
176+
https://api.nesy-prover.dev/api/health; done; echo
177+
178+
# Headers: HSTS present, Server header absent
179+
curl -sI https://api.nesy-prover.dev/api/health
180+
181+
# Isolation: the API port must NOT be reachable directly. This probe is
182+
# deliberately plaintext against the raw port — it is asserting that
183+
# nothing answers there, and must fail.
184+
curl -m 5 http://<SERVER_IP>:8081/api/health && echo "REACHABLE - FIX THIS" || echo "unreachable (correct)"
185+
----
186+
187+
Reboot survival (validates linger + `[Install]` + Restart in one shot):
188+
189+
[source,console]
190+
----
191+
sudo reboot
192+
# after it returns:
193+
podman ps && curl -s https://api.nesy-prover.dev/api/health
194+
----
195+
196+
== 5. Updates and rollback
197+
198+
`ghcr-publish.yml` pushes a new `:latest` on every GitHub release.
199+
Enable unattended rollout:
200+
201+
[source,console]
202+
----
203+
systemctl --user enable --now podman-auto-update.timer
204+
podman auto-update --dry-run # see what would change
205+
podman auto-update # roll out immediately
206+
----
207+
208+
How the safety net actually works: `podman auto-update` pulls the new
209+
image and restarts the unit. `HealthOnFailure=kill` in
210+
`echidna.container` means a container that goes unhealthy is killed,
211+
which fails the systemd unit — and auto-update rolls the unit back to
212+
the image it was running before when the restarted unit does not come
213+
up healthy. Without that key an unhealthy-but-running container would
214+
satisfy systemd and no rollback would occur, so do not remove it.
215+
216+
Manual rollback to a recorded digest. *Stop the timer first* —
217+
otherwise its next run pulls registry `:latest` again and silently
218+
undoes the rollback:
219+
220+
[source,console]
221+
----
222+
systemctl --user disable --now podman-auto-update.timer
223+
podman pull ghcr.io/hyperpolymath/echidna@<DIGEST>
224+
podman tag ghcr.io/hyperpolymath/echidna@<DIGEST> ghcr.io/hyperpolymath/echidna:latest
225+
systemctl --user restart echidna
226+
----
227+
228+
Re-enable the timer only once a fixed `:latest` has been published,
229+
or switch to pinned mode permanently: set `Image=` in
230+
`echidna.container` to a `:vX.Y.Z` tag or digest, drop
231+
`AutoUpdate=registry`, and bump it via PR.
232+
233+
== Appendix: fallback for podman without quadlet support
234+
235+
[source,console]
236+
----
237+
podman network create echidna-net
238+
podman create --name echidna --network echidna-net \
239+
--memory=1500m --pids-limit=512 --tmpfs /tmp:rw,size=256m \
240+
--security-opt no-new-privileges \
241+
--health-cmd '/app/bin/echidna --version' --health-interval 30s \
242+
--health-on-failure kill \
243+
ghcr.io/hyperpolymath/echidna:latest server --port 8081 --host 0.0.0.0 --cors
244+
podman create --name caddy --network echidna-net \
245+
-p 80:80 -p 443:443 \
246+
-v /opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro \
247+
-v caddy-data:/data -v caddy-config:/config \
248+
localhost/caddy-rl:2
249+
podman generate systemd --new --files --name echidna
250+
podman generate systemd --new --files --name caddy
251+
mkdir -p ~/.config/systemd/user && mv container-*.service ~/.config/systemd/user/
252+
systemctl --user daemon-reload
253+
systemctl --user enable --now container-echidna container-caddy
254+
----

deploy/hetzner/caddy.container

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Quadlet unit for the Caddy TLS proxy fronting api.nesy-prover.dev.
5+
#
6+
# Build the image on the host first:
7+
# podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
8+
# The caddy-data volume persists the ACME account and certificates —
9+
# do not delete it casually (Let's Encrypt rate limits).
10+
11+
[Unit]
12+
Description=Caddy TLS proxy for api.nesy-prover.dev
13+
Wants=echidna.service
14+
After=echidna.service
15+
16+
[Container]
17+
Image=localhost/caddy-rl:2
18+
ContainerName=caddy
19+
Network=echidna.network
20+
PublishPort=80:80
21+
PublishPort=443:443
22+
Volume=/opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro
23+
Volume=caddy-data:/data
24+
Volume=caddy-config:/config
25+
26+
[Service]
27+
Restart=always
28+
RestartSec=5
29+
30+
[Install]
31+
WantedBy=default.target

deploy/hetzner/echidna.container

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Quadlet unit for the ECHIDNA API server.
5+
#
6+
# The API has no authentication and spawns prover binaries on user
7+
# input, so it is caged: no published host port (only Caddy reaches it
8+
# over echidna-net), memory/pids limits, tmpfs /tmp, no privilege
9+
# escalation. Healthcheck uses the binary — the image ships no curl.
10+
#
11+
# Image tag :latest is intentional together with AutoUpdate=registry +
12+
# podman-auto-update.timer: new releases roll out automatically and a
13+
# failing healthcheck rolls back. For pinned mode, set Image= to a
14+
# :vX.Y.Z tag or digest and bump via PR (see README.adoc).
15+
16+
[Unit]
17+
Description=ECHIDNA theorem-prover API
18+
19+
[Container]
20+
Image=ghcr.io/hyperpolymath/echidna:latest
21+
AutoUpdate=registry
22+
ContainerName=echidna
23+
Network=echidna.network
24+
Exec=server --port 8081 --host 0.0.0.0 --cors
25+
Environment=RUST_LOG=info
26+
HealthCmd=/app/bin/echidna --version
27+
HealthInterval=30s
28+
# kill (not stop) is what integrates with systemd: an unhealthy
29+
# container fails the unit, which is what makes Restart=always and
30+
# podman-auto-update's rollback actually trigger.
31+
HealthOnFailure=kill
32+
Tmpfs=/tmp:rw,size=256m
33+
NoNewPrivileges=true
34+
PodmanArgs=--memory=1500m --pids-limit=512
35+
36+
[Service]
37+
Restart=always
38+
RestartSec=5
39+
40+
[Install]
41+
WantedBy=default.target

deploy/hetzner/echidna.network

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Shared podman network for the API + proxy pair. Referenced by name
5+
# (echidna.network) from both .container units so systemd orders
6+
# creation correctly.
7+
8+
[Network]
9+
NetworkName=echidna-net

0 commit comments

Comments
 (0)