Skip to content

Commit b2b2a14

Browse files
hyperpolymathclaude
andcommitted
feat(deploy): Hetzner deploy unit for api.nesy-prover.dev
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>
1 parent 0823afc commit b2b2a14

7 files changed

Lines changed: 335 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: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
(which rolls back automatically when the updated container fails its
24+
healthcheck). Requires podman >= 4.4.
25+
* *Rootless*: the API executes user-supplied prover input; run both
26+
units as an unprivileged `deploy` user with linger.
27+
* *Caddy over svalinn*: the estate's svalinn/vordr stack is not yet
28+
CI-proven; Caddy is the low-risk choice for a public endpoint. The
29+
`container/compose.toml` selur stack remains for local development.
30+
* *No API auth at launch*: the service is deliberately public but caged
31+
(rate limit, body cap, memory/pids limits, tmpfs, unpublished port).
32+
If abuse appears, add a bearer-token matcher in the Caddyfile for
33+
POST routes. A server-side per-request prover timeout is tracked as a
34+
follow-up issue in the main repo.
35+
36+
== 0. Preflight (read-only, do first)
37+
38+
ssh <user>@<SERVER_IP> 'cat /etc/os-release; free -h; df -h /; nproc; ss -tlnp; podman --version'
39+
40+
Gates — stop and reassess if any fail:
41+
42+
* Ports 80/443 unbound (`ss -tlnp` shows no listener). If something
43+
else owns them, this plan does not apply as-is.
44+
* >= 2 GB RAM free for the `:latest` image (Z3 + Lean). The `:full`
45+
image (Isabelle/Agda/Julia) needs >= 4 GB and is not deployed here.
46+
* podman >= 4.4 for quadlets. Debian 12 ships 4.3.1 — either install a
47+
newer podman or fall back to `podman generate systemd --new --files`
48+
(same containers, generated units instead of quadlets).
49+
* Image pullable anonymously:
50+
+
51+
podman manifest inspect ghcr.io/hyperpolymath/echidna:latest
52+
+
53+
On 401: make the GHCR package public (GitHub → Packages → echidna →
54+
Package settings → visibility) or use a read-only PAT with
55+
`podman login ghcr.io` as the deploy user.
56+
57+
== 1. Server preparation (as root)
58+
59+
apt update && apt upgrade -y
60+
apt install -y podman unattended-upgrades
61+
dpkg-reconfigure -f noninteractive unattended-upgrades
62+
63+
# Firewall: SSH + HTTP(S) only
64+
apt install -y ufw
65+
ufw default deny incoming
66+
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
67+
ufw enable
68+
69+
# Unprivileged deploy user with lingering services
70+
useradd -m -s /bin/bash deploy
71+
loginctl enable-linger deploy
72+
73+
# Let rootless podman bind 80/443
74+
echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/50-unprivileged-ports.conf
75+
sysctl --system
76+
77+
== 2. Install the deploy unit (as deploy)
78+
79+
From your workstation, from this directory:
80+
81+
scp Caddyfile Containerfile.caddy deploy@<SERVER_IP>:/opt/echidna/
82+
scp echidna.network echidna.container caddy.container \
83+
deploy@<SERVER_IP>:~/.config/containers/systemd/
84+
85+
(Create the target dirs first: `sudo mkdir -p /opt/echidna && sudo chown deploy /opt/echidna`
86+
and `mkdir -p ~/.config/containers/systemd` as deploy.)
87+
88+
On the server, as deploy:
89+
90+
cd /opt/echidna
91+
podman build -f Containerfile.caddy -t localhost/caddy-rl:2 .
92+
podman pull ghcr.io/hyperpolymath/echidna:latest
93+
podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
94+
# ^ record this digest in env.example / your notes for rollback
95+
96+
systemctl --user daemon-reload
97+
systemctl --user list-unit-files | grep -E 'echidna|caddy'
98+
99+
== 3. DNS (before starting Caddy)
100+
101+
Create in the Cloudflare zone for nesy-prover.dev:
102+
103+
A api <SERVER_IP> proxied=false (grey cloud), TTL 300
104+
105+
Grey cloud is required at launch so Caddy's Let's Encrypt HTTP-01
106+
challenge reaches the server directly. (Optional later hardening:
107+
orange-cloud + a Cloudflare Origin CA cert in the Caddyfile.)
108+
109+
Wait until `dig +short api.nesy-prover.dev @1.1.1.1` returns the
110+
server IP before the first Caddy start — ACME then succeeds first try.
111+
112+
== 4. Bring-up and verification
113+
114+
systemctl --user start echidna caddy
115+
podman ps # both Up, echidna (healthy)
116+
journalctl --user -u caddy -f # watch certificate issuance
117+
118+
End-to-end, from anywhere:
119+
120+
curl -s https://api.nesy-prover.dev/api/health # {"status":"ok",...}
121+
curl -s https://api.nesy-prover.dev/api/provers | head
122+
curl -s -X POST https://api.nesy-prover.dev/api/verify \
123+
-H 'content-type: application/json' \
124+
-d '{"prover":"Z3","content":"(assert (forall ((x Int)) (= (+ x 0) x)))(check-sat)"}'
125+
126+
Controls:
127+
128+
# Rate limit: burst 40 requests, expect HTTP 429 after ~30
129+
for i in $(seq 1 40); do curl -s -o /dev/null -w '%{http_code} ' \
130+
https://api.nesy-prover.dev/api/health; done; echo
131+
# Headers: HSTS present, Server header absent
132+
curl -sI https://api.nesy-prover.dev/api/health
133+
# Isolation: direct API port must be unreachable from outside
134+
curl -m 5 http://<SERVER_IP>:8081/api/health || echo "unreachable (correct)"
135+
136+
Reboot survival (validates linger + [Install] + Restart in one shot):
137+
138+
sudo reboot
139+
# after it returns:
140+
podman ps && curl -s https://api.nesy-prover.dev/api/health
141+
142+
== 5. Updates and rollback
143+
144+
Enable unattended rollout of new releases (ghcr-publish.yml pushes
145+
`:latest` on every GitHub release):
146+
147+
systemctl --user enable --now podman-auto-update.timer
148+
podman auto-update --dry-run # see what would change
149+
podman auto-update # roll out immediately
150+
151+
`podman-auto-update` restarts the unit on a new image and rolls back
152+
automatically if the fresh container fails its healthcheck.
153+
154+
Manual rollback to a recorded digest:
155+
156+
podman pull ghcr.io/hyperpolymath/echidna@<DIGEST>
157+
podman tag ghcr.io/hyperpolymath/echidna@<DIGEST> ghcr.io/hyperpolymath/echidna:latest
158+
systemctl --user restart echidna
159+
160+
Pinned mode (opt out of auto-update): set `Image=` in
161+
`echidna.container` to a `:vX.Y.Z` tag or digest, remove
162+
`AutoUpdate=registry`, and bump via PR.
163+
164+
== Appendix: Debian 12 fallback (podman 4.3, no quadlets)
165+
166+
podman network create echidna-net
167+
podman create --name echidna --network echidna-net \
168+
--memory=1500m --pids-limit=512 --tmpfs /tmp:rw,size=256m \
169+
--security-opt no-new-privileges \
170+
--health-cmd '/app/bin/echidna --version' --health-interval 30s \
171+
ghcr.io/hyperpolymath/echidna:latest server --port 8081 --host 0.0.0.0 --cors
172+
podman create --name caddy --network echidna-net \
173+
-p 80:80 -p 443:443 \
174+
-v /opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro \
175+
-v caddy-data:/data -v caddy-config:/config \
176+
localhost/caddy-rl:2
177+
podman generate systemd --new --files --name echidna
178+
podman generate systemd --new --files --name caddy
179+
mkdir -p ~/.config/systemd/user && mv container-*.service ~/.config/systemd/user/
180+
systemctl --user daemon-reload
181+
systemctl --user enable --now container-echidna container-caddy

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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
Tmpfs=/tmp:rw,size=256m
29+
NoNewPrivileges=true
30+
PodmanArgs=--memory=1500m --pids-limit=512
31+
32+
[Service]
33+
Restart=always
34+
RestartSec=5
35+
36+
[Install]
37+
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

deploy/hetzner/env.example

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Values that vary per deployment. Nothing here is secret by design —
5+
# the API takes no credentials; Cloudflare tokens are used from the
6+
# operator's workstation, never stored on the server.
7+
8+
# Public IPv4 of the Hetzner server (target of the api A record).
9+
SERVER_IP=
10+
11+
# Image reference actually deployed. :latest is the auto-update mode;
12+
# record the digest at deploy time for rollback:
13+
# podman image inspect --format '{{.Digest}}' ghcr.io/hyperpolymath/echidna:latest
14+
ECHIDNA_IMAGE=ghcr.io/hyperpolymath/echidna:latest
15+
ECHIDNA_IMAGE_DIGEST=
16+
17+
# ACME/Let's Encrypt contact (also set in the Caddyfile global block).
18+
ACME_EMAIL=j.d.a.jewell@open.ac.uk

0 commit comments

Comments
 (0)