collin/anvil
a064a2b0a02764789161f01801417d998a5b99ae / DEPLOY.md
RenderedSource
| 1 | # Deploying anvil on hagrid |
| 2 | |
| 3 | anvil runs as a single Docker container at `anvil.richardscollin.com`, fronted by |
| 4 | hagrid'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 | |
| 17 | Add an `A`/`AAAA` (or `CNAME` to hagrid) record: |
| 18 | |
| 19 | ``` |
| 20 | anvil.richardscollin.com -> <hagrid's public IP> |
| 21 | ``` |
| 22 | |
| 23 | This one record covers both the web (443, via Caddy) and SSH (2222, direct to |
| 24 | the host). |
| 25 | |
| 26 | ## 2. Caddy + index (in the hagrid repo) |
| 27 | |
| 28 | In `~/Code/hagrid/Caddyfile`, add: |
| 29 | |
| 30 | ``` |
| 31 | anvil.richardscollin.com { |
| 32 | reverse_proxy anvil:3000 |
| 33 | } |
| 34 | ``` |
| 35 | |
| 36 | In `~/Code/hagrid/sites.yaml`, add an entry: |
| 37 | |
| 38 | ```yaml |
| 39 | - name: anvil |
| 40 | host: anvil.richardscollin.com |
| 41 | ``` |
| 42 | |
| 43 | Then reload Caddy (`./hagrid.sh reload`, or `./hagrid.sh deploy` to push to the |
| 44 | host). Caddy resolves `anvil:3000` by container name over the `hagrid` network, |
| 45 | so 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 |
| 50 | one go — it opens a single multiplexed SSH connection to hagrid (so the key |
| 51 | passphrase 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 |
| 53 | describes the individual steps it composes. |
| 54 | |
| 55 | **Do not build on the VPS** — a release build needs ~2–4 GB peak and OOMs a |
| 56 | cheap, 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 | |
| 59 | One-time toolchain setup: |
| 60 | |
| 61 | ```sh |
| 62 | brew install zig |
| 63 | cargo install cargo-zigbuild |
| 64 | rustup target add x86_64-unknown-linux-musl |
| 65 | ``` |
| 66 | |
| 67 | Then, from a checkout of this repo on your Mac: |
| 68 | |
| 69 | ```sh |
| 70 | ./deploy/build.sh |
| 71 | ``` |
| 72 | |
| 73 | That: |
| 74 | 1. `cargo zigbuild --release --target x86_64-unknown-linux-musl` — cross-compiles |
| 75 | a fully static `x86_64`-musl binary natively (~2 min, no emulation), |
| 76 | 2. stages it at `deploy/anvild` and builds a thin image that just `COPY`s it in |
| 77 | (the `Dockerfile` does no compilation — fast), |
| 78 | 3. ships it: `docker save | gzip | ssh hagrid 'docker load'`. |
| 79 | |
| 80 | The VPS never compiles anything. |
| 81 | |
| 82 | ## 4. Run the container on hagrid |
| 83 | |
| 84 | On the hagrid host (only runs docker — no build): |
| 85 | |
| 86 | ```sh |
| 87 | ./deploy/run.sh |
| 88 | ``` |
| 89 | |
| 90 | which does: |
| 91 | |
| 92 | ```sh |
| 93 | docker 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 | |
| 118 | The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`). |
| 119 | Override 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 |
| 133 | docker 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) |
| 137 | docker 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 |
| 141 | docker 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 |
| 146 | signed in — the CLI is just convenient for the first admin.) |
| 147 | |
| 148 | ## 6. Self-host anvil on anvil |
| 149 | |
| 150 | From your local anvil checkout: |
| 151 | |
| 152 | ```sh |
| 153 | git remote add origin ssh://git@anvil.richardscollin.com:2222/collin/anvil.git |
| 154 | git push -u origin main |
| 155 | ``` |
| 156 | |
| 157 | Then browse it at `https://anvil.richardscollin.com/collin/anvil`. HTTPS clone |
| 158 | also 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 | |
| 163 | Any repo with a `.anvil/ci.yml` runs CI on push (see `[ci]` requires the Docker |
| 164 | socket mounted — section 4). A pipeline is just an image plus steps: |
| 165 | |
| 166 | ```yaml |
| 167 | image: rust:1.95-bookworm |
| 168 | steps: |
| 169 | - name: test |
| 170 | run: cargo test --workspace |
| 171 | - run: cargo build --release |
| 172 | ``` |
| 173 | |
| 174 | Runs show up at `/{owner}/{repo}/ci`, with a per-commit status badge on the |
| 175 | commit 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 |
| 178 | socket, no mounts (the checkout is uploaded as a tar), all capabilities |
| 179 | dropped, 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 |
| 183 | entry like `"rust"` allows every tag). See `docs/untrusted-mode.md` for the |
| 184 | threat model and what this does/doesn't protect against. |
| 185 | |
| 186 | **Continuous deployment** is deliberately scoped to **one** repository. On a |
| 187 | successful 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 | |
| 194 | No 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 |
| 196 | TLS-free) — typically a tiny script-runner on hagrid that, on a verified |
| 197 | request, 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 |
| 200 | built 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. |