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