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