-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(deploy): Hetzner deploy unit for api.nesy-prover.dev #330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
b2b2a14
feat(deploy): Hetzner deploy unit for api.nesy-prover.dev
hyperpolymath 8b418b5
fix(deploy): correct runbook ordering, rollback safety, and version g…
hyperpolymath 6893174
Merge branch 'main' into feat/hetzner-deploy-unit
hyperpolymath 99f274e
Merge branch 'main' into feat/hetzner-deploy-unit
hyperpolymath File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
| # | ||
| # TLS-terminating reverse proxy for the public ECHIDNA API. | ||
| # Runs in the localhost/caddy-rl:2 image (see Containerfile.caddy); | ||
| # the rate_limit directive is provided by mholt/caddy-ratelimit. | ||
|
|
||
| { | ||
| email j.d.a.jewell@open.ac.uk | ||
| order rate_limit before reverse_proxy | ||
| } | ||
|
|
||
| api.nesy-prover.dev { | ||
| encode zstd gzip | ||
|
|
||
| # The API executes prover input; cap request size and per-client | ||
| # rate before anything reaches it. | ||
| request_body { | ||
| max_size 1MB | ||
| } | ||
| rate_limit { | ||
| zone api { | ||
| key {remote_host} | ||
| events 30 | ||
| window 1m | ||
| } | ||
| } | ||
|
|
||
| header { | ||
| Strict-Transport-Security "max-age=31536000" | ||
| X-Content-Type-Options nosniff | ||
| X-Frame-Options DENY | ||
| Referrer-Policy no-referrer | ||
| -Server | ||
| } | ||
|
|
||
| # `echidna` resolves on the shared podman network (echidna.network); | ||
| # the API container publishes no host port. | ||
| reverse_proxy echidna:8081 { | ||
| transport http { | ||
| response_header_timeout 120s | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
| # | ||
| # Caddy with the mholt/caddy-ratelimit plugin (stock Caddy has no rate | ||
| # limiting). Built on the target host with podman — never published: | ||
| # | ||
| # podman build -f Containerfile.caddy -t localhost/caddy-rl:2 . | ||
| # | ||
| # RSR-H15: podman + Containerfile, not Docker. | ||
|
|
||
| FROM docker.io/library/caddy:2-builder AS builder | ||
| RUN xcaddy build --with github.com/mholt/caddy-ratelimit | ||
|
|
||
| FROM docker.io/library/caddy:2 | ||
| COPY --from=builder /usr/bin/caddy /usr/bin/caddy |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| // SPDX-License-Identifier: MPL-2.0 | ||
| // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
| = Deploying the ECHIDNA API to Hetzner (api.nesy-prover.dev) | ||
| :toc: | ||
| :toclevels: 2 | ||
|
|
||
| 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. | ||
|
|
||
| .What runs where | ||
| [cols="1,3"] | ||
| |=== | ||
| | `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. | ||
|
|
||
| == 0. Preflight (read-only, do first) | ||
|
|
||
| [source,console] | ||
| ---- | ||
| 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: | ||
| + | ||
| [source,console] | ||
| ---- | ||
| 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: | ||
| + | ||
| [source,console] | ||
| ---- | ||
| 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) | ||
|
|
||
| [source,console] | ||
| ---- | ||
| 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: | ||
|
|
||
| [source,console] | ||
| ---- | ||
| mkdir -p ~/.config/containers/systemd | ||
| ---- | ||
|
|
||
| Then, from your workstation in this directory: | ||
|
|
||
| [source,console] | ||
| ---- | ||
| 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: | ||
|
|
||
| [source,console] | ||
| ---- | ||
| 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: | ||
|
|
||
| [source] | ||
| ---- | ||
| 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 | ||
|
|
||
| [source,console] | ||
| ---- | ||
| systemctl --user start echidna caddy | ||
| podman ps # both Up, echidna (healthy) | ||
| journalctl --user -u caddy -f # watch certificate issuance | ||
| ---- | ||
|
|
||
| End-to-end, from anywhere: | ||
|
|
||
| [source,console] | ||
| ---- | ||
| curl -s https://api.nesy-prover.dev/api/health # {"status":"ok",...} | ||
| curl -s https://api.nesy-prover.dev/api/provers | head | ||
| curl -s -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)"}' | ||
| ---- | ||
|
|
||
| Controls: | ||
|
|
||
| [source,console] | ||
| ---- | ||
| # 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): | ||
|
|
||
| [source,console] | ||
| ---- | ||
| sudo reboot | ||
| # after it returns: | ||
| podman ps && curl -s https://api.nesy-prover.dev/api/health | ||
| ---- | ||
|
|
||
| == 5. Updates and rollback | ||
|
|
||
| `ghcr-publish.yml` pushes a new `:latest` on every GitHub release. | ||
| Enable unattended rollout: | ||
|
|
||
| [source,console] | ||
| ---- | ||
| 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: | ||
|
|
||
| [source,console] | ||
| ---- | ||
| 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-enable the timer only once a fixed `:latest` has been published, | ||
| or switch to pinned mode permanently: set `Image=` in | ||
| `echidna.container` to a `:vX.Y.Z` tag or digest, drop | ||
| `AutoUpdate=registry`, and bump it via PR. | ||
|
|
||
| == Appendix: fallback for podman without quadlet support | ||
|
|
||
| [source,console] | ||
| ---- | ||
| 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 | ||
| ---- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
| # | ||
| # Quadlet unit for the Caddy TLS proxy fronting api.nesy-prover.dev. | ||
| # | ||
| # Build the image on the host first: | ||
| # podman build -f Containerfile.caddy -t localhost/caddy-rl:2 . | ||
| # The caddy-data volume persists the ACME account and certificates — | ||
| # do not delete it casually (Let's Encrypt rate limits). | ||
|
|
||
| [Unit] | ||
| Description=Caddy TLS proxy for api.nesy-prover.dev | ||
| Wants=echidna.service | ||
| After=echidna.service | ||
|
|
||
| [Container] | ||
| Image=localhost/caddy-rl:2 | ||
| ContainerName=caddy | ||
| Network=echidna.network | ||
| PublishPort=80:80 | ||
| PublishPort=443:443 | ||
| Volume=/opt/echidna/Caddyfile:/etc/caddy/Caddyfile:ro | ||
| Volume=caddy-data:/data | ||
| Volume=caddy-config:/config | ||
|
|
||
| [Service] | ||
| Restart=always | ||
| RestartSec=5 | ||
|
|
||
| [Install] | ||
| WantedBy=default.target |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
| # | ||
| # Quadlet unit for the ECHIDNA API server. | ||
| # | ||
| # The API has no authentication and spawns prover binaries on user | ||
| # input, so it is caged: no published host port (only Caddy reaches it | ||
| # over echidna-net), memory/pids limits, tmpfs /tmp, no privilege | ||
| # escalation. Healthcheck uses the binary — the image ships no curl. | ||
| # | ||
| # Image tag :latest is intentional together with AutoUpdate=registry + | ||
| # podman-auto-update.timer: new releases roll out automatically and a | ||
| # failing healthcheck rolls back. For pinned mode, set Image= to a | ||
| # :vX.Y.Z tag or digest and bump via PR (see README.adoc). | ||
|
|
||
| [Unit] | ||
| Description=ECHIDNA theorem-prover API | ||
|
|
||
| [Container] | ||
| Image=ghcr.io/hyperpolymath/echidna:latest | ||
| AutoUpdate=registry | ||
| ContainerName=echidna | ||
| Network=echidna.network | ||
| Exec=server --port 8081 --host 0.0.0.0 --cors | ||
| Environment=RUST_LOG=info | ||
| HealthCmd=/app/bin/echidna --version | ||
| HealthInterval=30s | ||
| # kill (not stop) is what integrates with systemd: an unhealthy | ||
| # container fails the unit, which is what makes Restart=always and | ||
| # podman-auto-update's rollback actually trigger. | ||
| HealthOnFailure=kill | ||
| Tmpfs=/tmp:rw,size=256m | ||
| NoNewPrivileges=true | ||
| PodmanArgs=--memory=1500m --pids-limit=512 | ||
|
|
||
| [Service] | ||
| Restart=always | ||
| RestartSec=5 | ||
|
|
||
| [Install] | ||
| WantedBy=default.target | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
| # | ||
| # Shared podman network for the API + proxy pair. Referenced by name | ||
| # (echidna.network) from both .container units so systemd orders | ||
| # creation correctly. | ||
|
|
||
| [Network] | ||
| NetworkName=echidna-net |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.