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 -v /var/run/docker.sock:/var/run/docker.sock \
97 --group-add "$(stat -c '%g' /var/run/docker.sock)" \
98 anvil:latest
99```
100
101- `--network hagrid` — so Caddy can reach `anvil:3000`.
102- `-p 2222:2222` — publishes SSH to the host.
103- `-v anvil-data:/data` — a named volume holding the SQLite DB, the bare repos,
104 and the persistent SSH **host key**. Use a named volume (not a host bind
105 mount) so it's owned by the in-container `anvil` user.
106- `-v /var/run/docker.sock:/var/run/docker.sock` + `--group-add <sock gid>` —
107 lets the **CI runner** drive Docker on the host. The non-root `anvil` user
108 needs the socket's group to open it, hence `--group-add` with the socket's
109 gid (computed at run time by `run.sh`).
110
111> ⚠️ **Security:** mounting the Docker socket grants the container
112> **root-equivalent** control of the host. This is acceptable here because anvil
113> is **single-tenant and owner-operated** — CI only ever runs code *you* push.
114> Do **not** open this instance to untrusted users while the socket is mounted.
115> If you don't want CI, drop the `-v …docker.sock…` and `--group-add` flags;
116> the forge runs fine without them (CI runs just error out).
117
118The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`).
119Override it by bind-mounting your own file over that path.
120
121> **If you must build on the VPS anyway** (not recommended): give it swap and
122> cap parallelism, or it will OOM —
123> ```sh
124> sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile \
125> && sudo mkswap /swapfile && sudo swapon /swapfile # persist in /etc/fstab
126> # then build with CARGO_BUILD_JOBS=1 (slow, but survives 1 GB RAM)
127> ```
128
129## 5. First run: create your account, key, and the repo
130
131```sh
132# admin user
133docker exec anvil anvild -c /etc/anvil/anvil.toml \
134 user create collin --email you@example.com --password '<password>' --admin
135
136# your SSH public key (so you can push over SSH)
137docker exec -i anvil anvild -c /etc/anvil/anvil.toml \
138 user add-key collin --title laptop --key "$(cat ~/.ssh/id_ed25519.pub)"
139
140# the anvil repo itself
141docker exec anvil anvild -c /etc/anvil/anvil.toml repo create collin/anvil \
142 --description "a minimal git forge in Rust"
143```
144
145(You can also create the user, add keys, and create repos from the web UI once
146signed in — the CLI is just convenient for the first admin.)
147
148## 6. Self-host anvil on anvil
149
150From your local anvil checkout:
151
152```sh
153git remote add origin ssh://git@anvil.richardscollin.com:2222/collin/anvil.git
154git push -u origin main
155```
156
157Then browse it at `https://anvil.richardscollin.com/collin/anvil`. HTTPS clone
158also works: `git clone https://anvil.richardscollin.com/collin/anvil.git`
159(pushes over HTTPS require your account password as the git password).
160
161## 7. CI and the redeploy webhook (CD)
162
163Any repo with a `.anvil/ci.yml` runs CI on push (see `[ci]` requires the Docker
164socket mounted — section 4). A pipeline is just an image plus steps:
165
166```yaml
167image: rust:1.95-bookworm
168steps:
169 - name: test
170 run: cargo test --workspace
171 - run: cargo build --release
172```
173
174Runs show up at `/{owner}/{repo}/ci`, with a per-commit status badge on the
175commit list and a full log on each run's page.
176
177**Job sandbox.** anvil is the only Docker client; the job container gets no
178socket, no mounts (the checkout is uploaded as a tar), all capabilities
179dropped, and `no-new-privileges`. Resource bounds are configurable under
180`[ci]`: `memory_mb` (default 2048), `cpus` (2), `pids_limit` (512),
181`timeout_secs` (1800, then the container is killed), `network` (true),
182`run_as` (empty = image default), and `allowed_images` (empty = any; a tagless
183entry like `"rust"` allows every tag). See `docs/untrusted-mode.md` for the
184threat model and what this does/doesn't protect against.
185
186**Continuous deployment** is deliberately scoped to **one** repository. On a
187successful run of `deploy_branch` (default `main`) in the repo named by
188`[ci] deploy_repo`, anvil POSTs JSON to `[ci] deploy_webhook`:
189
190```json
191{ "repo": "collin/anvil", "ref": "main", "commit": "<oid>", "run_id": 42 }
192```
193
194No other repo can trigger this, even with passing CI. The webhook target is a
195**host-local plaintext** receiver (HTTPS is unsupported, to keep the build
196TLS-free) — typically a tiny script-runner on hagrid that, on a verified
197request, runs the actual redeploy. Verify the `X-Anvil-Deploy-Secret` header
198(set `[ci] deploy_secret`) before doing anything. Because anvil cross-compiles
199(section 3), "redeploy anvil" usually means: the receiver pulls the freshly
200built image and re-runs `deploy/run.sh` — it does **not** build in-place.
201
202> The deploy receiver runs with whatever privileges you give it — keep it
203> minimal, secret-gated, and bound to localhost / the Docker host gateway only.
204
205## Operations
206
207- **Update**: from the Mac, `./deploy/deploy.sh` (build + ship + restart in
208 one command, one passphrase prompt); or on hagrid, re-run `./deploy/run.sh`
209 to recreate the container from the already-loaded image (the `anvil-data`
210 volume persists). NOTE: adding new DB tables in a
211 future version won't auto-apply to an existing database yet (Toasty migration
212 support is pending) — the git repos on disk are unaffected, but repo metadata
213 in SQLite may need recreating until migrations land.
214- **Backup**: snapshot the `anvil-data` volume, e.g.
215 `docker run --rm -v anvil-data:/data -v "$PWD":/out debian:bookworm-slim \
216 tar czf /out/anvil-data.tgz -C /data .`
217- **Logs**: `docker logs -f anvil`.
218- **System routes** live under `/-/` (e.g. sign in at
219 `https://anvil.richardscollin.com/-/login`); `/{username}` is the user/repo
220 namespace.