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**: `:2222` in the container, mapped to `:22` on the
11 droplet's default public IPv4 (§4). Git-over-SSH connects to
12 `git@anvil.richardscollin.com` on the default port.
13- **Runtime** — fully self-contained: SQLite and the SSH crypto are compiled in,
14 and gix is pure-Rust. No git, OpenSSH, or system sqlite in the image.
15
16## 1. DNS
17
18Add an `A`/`AAAA` (or `CNAME` to hagrid) record:
19
20```
21anvil.richardscollin.com -> <hagrid's public IP>
22```
23
24This one record covers both the web (443, via Caddy) and SSH (22, direct to
25the host). The `A` record must be the same address `compose.yaml` binds the SSH
26port to — today `165.232.162.167` (§4).
27
28## 2. Caddy + index (in the hagrid repo)
29
30In `~/Code/hagrid/Caddyfile`, add:
31
32```
33anvil.richardscollin.com {
34 reverse_proxy anvil:3000
35}
36```
37
38In `~/Code/hagrid/sites.yaml`, add an entry:
39
40```yaml
41- name: anvil
42 host: anvil.richardscollin.com
43```
44
45Then reload Caddy (`./hagrid.sh reload`, or `./hagrid.sh deploy` to push to the
46host). Caddy resolves `anvil:3000` by container name over the `hagrid` network,
47so the anvil container must join that network (`compose.yaml` does this).
48
49## 3. Deploying: `compose.yaml` + `hag`
50
51`compose.yaml` at the repo root describes the deployment — the image, the
52network, the volume, the published SSH port. Running it on hagrid is
53[`hag`](https://anvil.richardscollin.com/collin/hagrid), the shared deploy tool
54for every project on that host: it builds the image, pushes it to
55`registry.vibe.richardscollin.com`, copies `compose.yaml` up, then pulls and
56recreates the container there. Install it once with `~/Code/hagrid/hag install`.
57
58`compose.override.yaml` sits next to it for local development — loopback ports,
59the dev config, the Docker socket agent sessions need. Compose merges it
60automatically when you run `docker compose` in the repo root, and `hag` always
61passes `-f compose.yaml` explicitly, so none of it ever reaches hagrid. See the
62README for the local loop.
63
64anvil needs one step in front of that, because the image carries a **prebuilt**
65binary — `deploy/deploy.sh` is the whole deploy:
66
67```sh
68./deploy/deploy.sh # cross-compile, build, push, restart on hagrid
69./deploy/deploy.sh -n # dry run: print every step, change nothing
70```
71
72which is:
73
741. `deploy/build.sh` — `cargo zigbuild --release --target
75 x86_64-unknown-linux-musl` cross-compiles a fully static binary natively
76 (~2 min, no emulation) and stages it at `deploy/anvild`,
772. `hag deploy` — `docker compose build --push` builds the thin image that just
78 `COPY`s that binary in (the `Dockerfile` compiles nothing), pushes it, then
79 drives the host.
80
81**Do not build on the VPS** — a release build needs ~2–4 GB peak and OOMs a
82cheap, swap-less droplet. The VPS never compiles anything; it only pulls.
83
84One-time toolchain setup:
85
86```sh
87dnf install zig # or: brew install zig
88cargo install cargo-zigbuild
89rustup target add x86_64-unknown-linux-musl
90docker login registry.vibe.richardscollin.com # on this machine and hagrid
91```
92
93Every build is tagged twice: with the short git sha of the checkout (plus a
94`-dirty` suffix when the working tree has uncommitted changes) and with
95`latest`. The deploy pins the host to the exact sha it just pushed, so `hag
96status` always names the running build. To roll back, run an older tag on the
97host:
98
99```sh
100ssh hagrid 'cd anvil && IMAGE_TAG=<sha> docker compose up -d --no-build'
101```
102
103Managing it afterwards needs no ssh and no remembering where it lives:
104
105```sh
106hag status # what the host is running
107hag logs -f # its logs
108hag restart # recreate its containers
109hag down # stop it
110hag config # what hag resolved for this project
111hag ls # every compose project on the host
112```
113
114## 4. What `compose.yaml` runs
115
116```yaml
117image: registry.vibe.richardscollin.com/anvil:${IMAGE_TAG:-latest}
118container_name: anvil
119restart: unless-stopped
120ports: ["165.232.162.167:22:2222"]
121volumes: [anvil-data:/data]
122networks: [hagrid]
123```
124
125- `networks: [hagrid]` — an external network, so Caddy can reach `anvil:3000`.
126 The web port is deliberately **not** published.
127- `ports` — git-over-SSH, published on the droplet's *default* public IPv4 only.
128 The reserved IP (`137.184.249.48`) arrives on the anchor address `10.15.0.6`,
129 where the host's own sshd listens, so binding one specific IP keeps the two
130 off each other. `ANVIL_SSH_BIND_IP` / `ANVIL_SSH_PORT` in the host's `.env`
131 override it.
132- `anvil-data:/data` — a named volume holding the SQLite DB, the bare repos and
133 the persistent SSH **host key**. Pinned with `name:` to that exact string, so
134 compose adopts the volume the pre-compose deploys created instead of deriving
135 a fresh empty `anvil_anvil-data` from the project name. A named volume (not a
136 host bind mount) keeps it owned by the in-container `anvil` user.
137- **No Docker socket.** anvil does not execute CI — runners dial in and run
138 jobs on their own daemons (see [docs/remote-runners.md](docs/remote-runners.md)).
139 The container has no reason to reach Docker, so the mount is gone, and with
140 it the root-equivalent hold the internet-facing process used to have on the
141 host.
142
143> **Agent sessions are the exception.** They still drive Docker locally, so
144> turning them on means putting the socket back — a `volumes:` entry for
145> `/var/run/docker.sock` and a matching `group_add:` — and accepting that the
146> container again has **root-equivalent** control of the host. They are off by
147> default and absent from `deploy/anvil.toml`; read `docs/untrusted-mode.md`
148> §7 before changing that.
149
150The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`).
151Override it by bind-mounting your own file over that path.
152
153**Host-side config.** `compose.yaml` loads `~/anvil/.env` on the host if it
154exists, and skips it if not — `hag` never copies a local `.env` up, so the
155workstation and the host keep separate config. Today that file carries the SSO
156client secret (§ [docs/oidc.md](docs/oidc.md)):
157
158```sh
159ssh hagrid 'printf "ANVIL_OIDC_CLIENT_SECRET=%s\n" \
160 "$(tr -d "[:space:]" < ~/.config/anvil/oidc-client-secret)" > ~/anvil/.env \
161 && chmod 600 ~/anvil/.env'
162```
163
164Leave the variable out entirely rather than setting it empty: empty would
165override the baked config and turn a confidential OIDC client into a public one.
166
167**Migrating from the `docker run` deploys.** The old container was created by
168`docker run`, so it carries no compose labels; compose reads `container_name:
169anvil` as taken by a foreign container and refuses to adopt it. Clear it once,
170before the first compose deploy:
171
172```sh
173ssh hagrid 'docker rm -f anvil'
174```
175
176The `anvil-data` volume is untouched by that and is what the new container
177picks up.
178
179## 5. First run: create your account, key, and the repo
180
181```sh
182# admin user
183docker exec anvil anvild -c /etc/anvil/anvil.toml \
184 user create collin --email you@example.com --password '<password>' --admin
185
186# your SSH public key (so you can push over SSH)
187docker exec -i anvil anvild -c /etc/anvil/anvil.toml \
188 user add-key collin --title laptop --key "$(cat ~/.ssh/id_ed25519.pub)"
189
190# the anvil repo itself
191docker exec anvil anvild -c /etc/anvil/anvil.toml repo create collin/anvil \
192 --description "a minimal git forge in Rust"
193```
194
195(You can also create the user, add keys, and create repos from the web UI once
196signed in — the CLI is just convenient for the first admin.)
197
198## 6. Self-host anvil on anvil
199
200From your local anvil checkout:
201
202```sh
203git remote add origin ssh://git@anvil.richardscollin.com/collin/anvil.git
204git push -u origin main
205```
206
207Then browse it at `https://anvil.richardscollin.com/collin/anvil`. HTTPS clone
208also works: `git clone https://anvil.richardscollin.com/collin/anvil.git`
209(pushes over HTTPS require your account password as the git password).
210
211## 7. CI: start a runner
212
213anvil dispatches CI; it does not execute it. Nothing runs until a runner dials
214in, so this is a required step, not an optional one — see
215[docs/remote-runners.md](docs/remote-runners.md).
216
217Set a shared secret in the config (`[ci] runner_token`), then on a machine with
218room to build — the Mac mini, not the droplet:
219
220```sh
221cargo build --release --bin anvil-worker
222cp target/release/anvil-worker /usr/local/bin/
223
224anvil-worker --url https://anvil.richardscollin.com \
225 --token "$ANVIL_RUNNER_TOKEN" --name macmini
226```
227
228`deploy/worker/com.anvil.worker.plist` runs it under launchd on macOS. Run it
229natively there rather than in a container: a containerized runner needs the
230Docker socket mounted into it, which rebuilds the hole section 4 just removed.
231
232Isolation is not weaker for being on a Mac. Docker Desktop runs every container
233inside one Linux VM, so `--cap-drop=ALL`, `no-new-privileges` and the
234cgroup limits are enforced by the same kernel primitives as on Linux — with the
235VM as an extra boundary a bare-metal Linux host does not have.
236
237**Architecture.** The Mac is arm64 and hagrid is x86_64, so a job running
238`cargo test` on the runner tests an architecture you do not ship. Images are
239unaffected (`deploy/build.sh` cross-compiles, and `compose.yaml` pins
240`platforms: [linux/amd64]`), and per-pipeline platform selection is designed but
241not yet wired to a config key.
242
243## 8. The redeploy webhook (CD)
244
245Any repo with a `.anvil/ci.yml` runs CI on push. A pipeline is just an image
246plus steps:
247
248```yaml
249image: rust:1.95-bookworm
250steps:
251 - name: test
252 run: cargo test --workspace
253 - run: cargo build --release
254```
255
256Runs show up at `/{owner}/{repo}/ci`, with a per-commit status badge on the
257commit list and a full log on each run's page.
258
259**Job sandbox.** The runner is the only Docker client on its machine; the job
260container gets no socket, no mounts (the checkout is uploaded as a tar), all
261capabilities dropped, and `no-new-privileges`. Resource bounds come from the
262forge's `[ci]` config and travel with each job, so tightening one does not need
263runners redeployed: `memory_mb` (default 2048), `cpus` (2), `pids_limit` (512),
264`timeout_secs` (1800, then the container is killed), `network` (true),
265`run_as` (empty = image default), and `allowed_images` (empty = any; a tagless
266entry like `"rust"` allows every tag). The allowlist is applied on the forge
267when the job is built, so a runner cannot widen it. See
268`docs/untrusted-mode.md` for the threat model and what this does/doesn't
269protect against.
270
271**Secrets now leave the host.** A pipeline's secrets are sent to the runner
272with its job and sit in plaintext in a container on a machine anvil does not
273own. Scope repository secrets accordingly, and treat a runner host as being as
274trusted as the forge itself.
275
276**Continuous deployment** is deliberately scoped to **one** repository. On a
277successful run of `deploy_branch` (default `main`) in the repo named by
278`[ci] deploy_repo`, anvil POSTs JSON to `[ci] deploy_webhook`:
279
280```json
281{ "repo": "collin/anvil", "ref": "main", "commit": "<oid>", "run_id": 42 }
282```
283
284No other repo can trigger this, even with passing CI. The webhook target is a
285**host-local plaintext** receiver (HTTPS is unsupported, to keep the build
286TLS-free) — typically a tiny script-runner on hagrid that, on a verified
287request, runs the actual redeploy. Verify the `X-Anvil-Deploy-Secret` header
288(set `[ci] deploy_secret`) before doing anything. Because anvil cross-compiles
289(section 3), "redeploy anvil" usually means: the receiver pulls the freshly
290built image and runs `docker compose up -d --no-build` in `~/anvil` — it does
291**not** build in-place.
292
293**Building an image is not a CI job.** Job containers get no Docker socket by
294design, so `docker build`/`docker push` cannot happen inside a pipeline. That
295work belongs to a separate deploy agent on the build host, triggered by this
296webhook, running *outside* the sandbox with Docker access — a different trust
297level from the runner, and deliberately a different process.
298
299> The deploy receiver runs with whatever privileges you give it — keep it
300> minimal, secret-gated, and bound to localhost / the Docker host gateway only.
301
302## Operations
303
304- **Update**: `./deploy/deploy.sh` (cross-compile + push + restart in one
305 command); or `hag restart` to recreate the container from the image already
306 on the host (the `anvil-data` volume persists). NOTE: adding new DB tables in a
307 future version won't auto-apply to an existing database yet (Toasty migration
308 support is pending) — the git repos on disk are unaffected, but repo metadata
309 in SQLite may need recreating until migrations land.
310- **Backup**: snapshot the `anvil-data` volume, e.g.
311 `docker run --rm -v anvil-data:/data -v "$PWD":/out debian:bookworm-slim \
312 tar czf /out/anvil-data.tgz -C /data .`
313- **Logs**: `hag logs -f` (or `docker logs -f anvil` on the host).
314- **System routes** live under `/-/` (e.g. sign in at
315 `https://anvil.richardscollin.com/-/login`); `/{username}` is the user/repo
316 namespace.