anvilsign in

collin/anvil

RenderedSource

1# Deploying anvil on hagrid
2
3anvil runs as a single Docker container at `anvil.richardscollin.com`, fronted by
4hagrid's Caddy reverse proxy.
5
6- **Web** — anvil listens on `:3000` *inside* the container. Caddy (on the
7 `hagrid` Docker network) reverse-proxies to it and provides HTTPS via Let's
8 Encrypt. The web port is **not** published to the host.
9- **SSH** — Caddy only fronts HTTP(S), so anvil's SSH server is **published
10 directly to the host** on `:2222`. Git-over-SSH connects to
11 `anvil.richardscollin.com:2222`.
12- **Runtime** — fully self-contained: SQLite and the SSH crypto are compiled in,
13 and gix is pure-Rust. No git, OpenSSH, or system sqlite in the image.
14
15## 1. DNS
16
17Add an `A`/`AAAA` (or `CNAME` to hagrid) record:
18
19```
20anvil.richardscollin.com -> <hagrid's public IP>
21```
22
23This one record covers both the web (443, via Caddy) and SSH (2222, direct to
24the host).
25
26## 2. Caddy + index (in the hagrid repo)
27
28In `~/Code/hagrid/Caddyfile`, add:
29
30```
31anvil.richardscollin.com {
32 reverse_proxy anvil:3000
33}
34```
35
36In `~/Code/hagrid/sites.yaml`, add an entry:
37
38```yaml
39- name: anvil
40 host: anvil.richardscollin.com
41```
42
43Then reload Caddy (`./hagrid.sh reload`, or `./hagrid.sh deploy` to push to the
44host). Caddy resolves `anvil:3000` by container name over the `hagrid` network,
45so the anvil container must join that network (the run script does this).
46
47## 3. Build the image on your Mac, ship it to hagrid
48
49**One-shot:** `./deploy/deploy.sh` does this whole section *and* section 4 in
50one go — it opens a single multiplexed SSH connection to hagrid (so the key
51passphrase is asked at most once), runs `build.sh` over it, then pipes
52`run.sh` to the host to restart the container. The rest of this section
53describes the individual steps it composes.
54
55**Do not build on the VPS** — a release build needs ~2–4 GB peak and OOMs a
56cheap, swap-less droplet. Instead, cross-compile a static binary on your Mac
57(native speed, no QEMU) and copy it into a thin image.
58
59One-time toolchain setup:
60
61```sh
62brew install zig
63cargo install cargo-zigbuild
64rustup target add x86_64-unknown-linux-musl
65```
66
67Then, from a checkout of this repo on your Mac:
68
69```sh
70./deploy/build.sh
71```
72
73That:
741. `cargo zigbuild --release --target x86_64-unknown-linux-musl` — cross-compiles
75 a fully static `x86_64`-musl binary natively (~2 min, no emulation),
762. stages it at `deploy/anvild` and builds a thin image that just `COPY`s it in
77 (the `Dockerfile` does no compilation — fast),
783. ships it: `docker save | gzip | ssh hagrid 'docker load'`.
79
80The VPS never compiles anything.
81
82## 4. Run the container on hagrid
83
84On the hagrid host (only runs docker — no build):
85
86```sh
87./deploy/run.sh
88```
89
90which does:
91
92```sh
93docker run -d --name anvil --network hagrid --restart unless-stopped \
94 -p 2222:2222 \
95 -v anvil-data:/data \
96 anvil:latest
97```
98
99- `--network hagrid` — so Caddy can reach `anvil:3000`.
100- `-p 2222:2222` — publishes SSH to the host.
101- `-v anvil-data:/data` — a named volume holding the SQLite DB, the bare repos,
102 and the persistent SSH **host key**. Use a named volume (not a host bind
103 mount) so it's owned by the in-container `anvil` user.
104- **No Docker socket.** anvil does not execute CI — runners dial in and run
105 jobs on their own daemons (see [docs/remote-runners.md](docs/remote-runners.md)).
106 The container has no reason to reach Docker, so the mount is gone, and with
107 it the root-equivalent hold the internet-facing process used to have on the
108 host.
109
110> **Agent sessions are the exception.** They still drive Docker locally, so
111> turning them on means putting the socket back
112> (`ANVIL_DOCKER_SOCK=/var/run/docker.sock ./deploy/run.sh`) and accepting that
113> the container again has **root-equivalent** control of the host. They are off
114> by default and absent from `deploy/anvil.toml`; read `docs/untrusted-mode.md`
115> §7 before changing that.
116
117The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`).
118Override it by bind-mounting your own file over that path.
119
120> **If you must build on the VPS anyway** (not recommended): give it swap and
121> cap parallelism, or it will OOM —
122> ```sh
123> sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile \
124> && sudo mkswap /swapfile && sudo swapon /swapfile # persist in /etc/fstab
125> # then build with CARGO_BUILD_JOBS=1 (slow, but survives 1 GB RAM)
126> ```
127
128## 5. First run: create your account, key, and the repo
129
130```sh
131# admin user
132docker exec anvil anvild -c /etc/anvil/anvil.toml \
133 user create collin --email you@example.com --password '<password>' --admin
134
135# your SSH public key (so you can push over SSH)
136docker exec -i anvil anvild -c /etc/anvil/anvil.toml \
137 user add-key collin --title laptop --key "$(cat ~/.ssh/id_ed25519.pub)"
138
139# the anvil repo itself
140docker exec anvil anvild -c /etc/anvil/anvil.toml repo create collin/anvil \
141 --description "a minimal git forge in Rust"
142```
143
144(You can also create the user, add keys, and create repos from the web UI once
145signed in — the CLI is just convenient for the first admin.)
146
147## 6. Self-host anvil on anvil
148
149From your local anvil checkout:
150
151```sh
152git remote add origin ssh://git@anvil.richardscollin.com:2222/collin/anvil.git
153git push -u origin main
154```
155
156Then browse it at `https://anvil.richardscollin.com/collin/anvil`. HTTPS clone
157also works: `git clone https://anvil.richardscollin.com/collin/anvil.git`
158(pushes over HTTPS require your account password as the git password).
159
160## 7. CI: start a runner
161
162anvil dispatches CI; it does not execute it. Nothing runs until a runner dials
163in, so this is a required step, not an optional one — see
164[docs/remote-runners.md](docs/remote-runners.md).
165
166Set a shared secret in the config (`[ci] runner_token`), then on a machine with
167room to build — the Mac mini, not the droplet:
168
169```sh
170cargo build --release --bin anvil-worker
171cp target/release/anvil-worker /usr/local/bin/
172
173anvil-worker --url https://anvil.richardscollin.com \
174 --token "$ANVIL_RUNNER_TOKEN" --name macmini
175```
176
177`deploy/worker/com.anvil.worker.plist` runs it under launchd on macOS. Run it
178natively there rather than in a container: a containerized runner needs the
179Docker socket mounted into it, which rebuilds the hole section 4 just removed.
180
181Isolation is not weaker for being on a Mac. Docker Desktop runs every container
182inside one Linux VM, so `--cap-drop=ALL`, `no-new-privileges` and the
183cgroup limits are enforced by the same kernel primitives as on Linux — with the
184VM as an extra boundary a bare-metal Linux host does not have.
185
186**Architecture.** The Mac is arm64 and hagrid is x86_64, so a job running
187`cargo test` on the runner tests an architecture you do not ship. Images are
188unaffected (`deploy/build.sh` already cross-compiles and builds
189`--platform linux/amd64`), and per-pipeline platform selection is designed but
190not yet wired to a config key.
191
192## 8. The redeploy webhook (CD)
193
194Any repo with a `.anvil/ci.yml` runs CI on push. A pipeline is just an image
195plus steps:
196
197```yaml
198image: rust:1.95-bookworm
199steps:
200 - name: test
201 run: cargo test --workspace
202 - run: cargo build --release
203```
204
205Runs show up at `/{owner}/{repo}/ci`, with a per-commit status badge on the
206commit list and a full log on each run's page.
207
208**Job sandbox.** The runner is the only Docker client on its machine; the job
209container gets no socket, no mounts (the checkout is uploaded as a tar), all
210capabilities dropped, and `no-new-privileges`. Resource bounds come from the
211forge's `[ci]` config and travel with each job, so tightening one does not need
212runners redeployed: `memory_mb` (default 2048), `cpus` (2), `pids_limit` (512),
213`timeout_secs` (1800, then the container is killed), `network` (true),
214`run_as` (empty = image default), and `allowed_images` (empty = any; a tagless
215entry like `"rust"` allows every tag). The allowlist is applied on the forge
216when the job is built, so a runner cannot widen it. See
217`docs/untrusted-mode.md` for the threat model and what this does/doesn't
218protect against.
219
220**Secrets now leave the host.** A pipeline's secrets are sent to the runner
221with its job and sit in plaintext in a container on a machine anvil does not
222own. Scope repository secrets accordingly, and treat a runner host as being as
223trusted as the forge itself.
224
225**Continuous deployment** is deliberately scoped to **one** repository. On a
226successful run of `deploy_branch` (default `main`) in the repo named by
227`[ci] deploy_repo`, anvil POSTs JSON to `[ci] deploy_webhook`:
228
229```json
230{ "repo": "collin/anvil", "ref": "main", "commit": "<oid>", "run_id": 42 }
231```
232
233No other repo can trigger this, even with passing CI. The webhook target is a
234**host-local plaintext** receiver (HTTPS is unsupported, to keep the build
235TLS-free) — typically a tiny script-runner on hagrid that, on a verified
236request, runs the actual redeploy. Verify the `X-Anvil-Deploy-Secret` header
237(set `[ci] deploy_secret`) before doing anything. Because anvil cross-compiles
238(section 3), "redeploy anvil" usually means: the receiver pulls the freshly
239built image and re-runs `deploy/run.sh` — it does **not** build in-place.
240
241**Building an image is not a CI job.** Job containers get no Docker socket by
242design, so `docker build`/`docker push` cannot happen inside a pipeline. That
243work belongs to a separate deploy agent on the build host, triggered by this
244webhook, running *outside* the sandbox with Docker access — a different trust
245level from the runner, and deliberately a different process.
246
247> The deploy receiver runs with whatever privileges you give it — keep it
248> minimal, secret-gated, and bound to localhost / the Docker host gateway only.
249
250## Operations
251
252- **Update**: from the Mac, `./deploy/deploy.sh` (build + ship + restart in
253 one command, one passphrase prompt); or on hagrid, re-run `./deploy/run.sh`
254 to recreate the container from the already-loaded image (the `anvil-data`
255 volume persists). NOTE: adding new DB tables in a
256 future version won't auto-apply to an existing database yet (Toasty migration
257 support is pending) — the git repos on disk are unaffected, but repo metadata
258 in SQLite may need recreating until migrations land.
259- **Backup**: snapshot the `anvil-data` volume, e.g.
260 `docker run --rm -v anvil-data:/data -v "$PWD":/out debian:bookworm-slim \
261 tar czf /out/anvil-data.tgz -C /data .`
262- **Logs**: `docker logs -f anvil`.
263- **System routes** live under `/-/` (e.g. sign in at
264 `https://anvil.richardscollin.com/-/login`); `/{username}` is the user/repo
265 namespace.