anvilsign in

collin/anvil

main / DEPLOY.md

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