collin/anvil
12a873243540893c43dee47e9a55884c3f48dece / 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 | anvil:latest |
| 97 | ``` |
| 98 | |
| 99 | - `--network hagrid` — so Caddy can reach `anvil:3000`. |
| 100 | - `-p 2222:2222` — publishes SSH to the host. |
| 101 | - `-v anvil-data:/data` — a named volume holding the SQLite DB, the bare repos, |
| 102 | and the persistent SSH **host key**. Use a named volume (not a host bind |
| 103 | mount) so it's owned by the in-container `anvil` user. |
| 104 | - **No Docker socket.** anvil does not execute CI — runners dial in and run |
| 105 | jobs on their own daemons (see [docs/remote-runners.md](docs/remote-runners.md)). |
| 106 | The container has no reason to reach Docker, so the mount is gone, and with |
| 107 | it the root-equivalent hold the internet-facing process used to have on the |
| 108 | host. |
| 109 | |
| 110 | > **Agent sessions are the exception.** They still drive Docker locally, so |
| 111 | > turning them on means putting the socket back |
| 112 | > (`ANVIL_DOCKER_SOCK=/var/run/docker.sock ./deploy/run.sh`) and accepting that |
| 113 | > the container again has **root-equivalent** control of the host. They are off |
| 114 | > by default and absent from `deploy/anvil.toml`; read `docs/untrusted-mode.md` |
| 115 | > §7 before changing that. |
| 116 | |
| 117 | The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`). |
| 118 | Override it by bind-mounting your own file over that path. |
| 119 | |
| 120 | > **If you must build on the VPS anyway** (not recommended): give it swap and |
| 121 | > cap parallelism, or it will OOM — |
| 122 | > ```sh |
| 123 | > sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile \ |
| 124 | > && sudo mkswap /swapfile && sudo swapon /swapfile # persist in /etc/fstab |
| 125 | > # then build with CARGO_BUILD_JOBS=1 (slow, but survives 1 GB RAM) |
| 126 | > ``` |
| 127 | |
| 128 | ## 5. First run: create your account, key, and the repo |
| 129 | |
| 130 | ```sh |
| 131 | # admin user |
| 132 | docker exec anvil anvild -c /etc/anvil/anvil.toml \ |
| 133 | user create collin --email you@example.com --password '<password>' --admin |
| 134 | |
| 135 | # your SSH public key (so you can push over SSH) |
| 136 | docker exec -i anvil anvild -c /etc/anvil/anvil.toml \ |
| 137 | user add-key collin --title laptop --key "$(cat ~/.ssh/id_ed25519.pub)" |
| 138 | |
| 139 | # the anvil repo itself |
| 140 | docker exec anvil anvild -c /etc/anvil/anvil.toml repo create collin/anvil \ |
| 141 | --description "a minimal git forge in Rust" |
| 142 | ``` |
| 143 | |
| 144 | (You can also create the user, add keys, and create repos from the web UI once |
| 145 | signed in — the CLI is just convenient for the first admin.) |
| 146 | |
| 147 | ## 6. Self-host anvil on anvil |
| 148 | |
| 149 | From your local anvil checkout: |
| 150 | |
| 151 | ```sh |
| 152 | git remote add origin ssh://git@anvil.richardscollin.com:2222/collin/anvil.git |
| 153 | git push -u origin main |
| 154 | ``` |
| 155 | |
| 156 | Then browse it at `https://anvil.richardscollin.com/collin/anvil`. HTTPS clone |
| 157 | also works: `git clone https://anvil.richardscollin.com/collin/anvil.git` |
| 158 | (pushes over HTTPS require your account password as the git password). |
| 159 | |
| 160 | ## 7. CI: start a runner |
| 161 | |
| 162 | anvil dispatches CI; it does not execute it. Nothing runs until a runner dials |
| 163 | in, so this is a required step, not an optional one — see |
| 164 | [docs/remote-runners.md](docs/remote-runners.md). |
| 165 | |
| 166 | Set a shared secret in the config (`[ci] runner_token`), then on a machine with |
| 167 | room to build — the Mac mini, not the droplet: |
| 168 | |
| 169 | ```sh |
| 170 | cargo build --release --bin anvil-worker |
| 171 | cp target/release/anvil-worker /usr/local/bin/ |
| 172 | |
| 173 | anvil-worker --url https://anvil.richardscollin.com \ |
| 174 | --token "$ANVIL_RUNNER_TOKEN" --name macmini |
| 175 | ``` |
| 176 | |
| 177 | `deploy/worker/com.anvil.worker.plist` runs it under launchd on macOS. Run it |
| 178 | natively there rather than in a container: a containerized runner needs the |
| 179 | Docker socket mounted into it, which rebuilds the hole section 4 just removed. |
| 180 | |
| 181 | Isolation is not weaker for being on a Mac. Docker Desktop runs every container |
| 182 | inside one Linux VM, so `--cap-drop=ALL`, `no-new-privileges` and the |
| 183 | cgroup limits are enforced by the same kernel primitives as on Linux — with the |
| 184 | VM as an extra boundary a bare-metal Linux host does not have. |
| 185 | |
| 186 | **Architecture.** The Mac is arm64 and hagrid is x86_64, so a job running |
| 187 | `cargo test` on the runner tests an architecture you do not ship. Images are |
| 188 | unaffected (`deploy/build.sh` already cross-compiles and builds |
| 189 | `--platform linux/amd64`), and per-pipeline platform selection is designed but |
| 190 | not yet wired to a config key. |
| 191 | |
| 192 | ## 8. The redeploy webhook (CD) |
| 193 | |
| 194 | Any repo with a `.anvil/ci.yml` runs CI on push. A pipeline is just an image |
| 195 | plus steps: |
| 196 | |
| 197 | ```yaml |
| 198 | image: rust:1.95-bookworm |
| 199 | steps: |
| 200 | - name: test |
| 201 | run: cargo test --workspace |
| 202 | - run: cargo build --release |
| 203 | ``` |
| 204 | |
| 205 | Runs show up at `/{owner}/{repo}/ci`, with a per-commit status badge on the |
| 206 | commit list and a full log on each run's page. |
| 207 | |
| 208 | **Job sandbox.** The runner is the only Docker client on its machine; the job |
| 209 | container gets no socket, no mounts (the checkout is uploaded as a tar), all |
| 210 | capabilities dropped, and `no-new-privileges`. Resource bounds come from the |
| 211 | forge's `[ci]` config and travel with each job, so tightening one does not need |
| 212 | runners redeployed: `memory_mb` (default 2048), `cpus` (2), `pids_limit` (512), |
| 213 | `timeout_secs` (1800, then the container is killed), `network` (true), |
| 214 | `run_as` (empty = image default), and `allowed_images` (empty = any; a tagless |
| 215 | entry like `"rust"` allows every tag). The allowlist is applied on the forge |
| 216 | when the job is built, so a runner cannot widen it. See |
| 217 | `docs/untrusted-mode.md` for the threat model and what this does/doesn't |
| 218 | protect against. |
| 219 | |
| 220 | **Secrets now leave the host.** A pipeline's secrets are sent to the runner |
| 221 | with its job and sit in plaintext in a container on a machine anvil does not |
| 222 | own. Scope repository secrets accordingly, and treat a runner host as being as |
| 223 | trusted as the forge itself. |
| 224 | |
| 225 | **Continuous deployment** is deliberately scoped to **one** repository. On a |
| 226 | successful run of `deploy_branch` (default `main`) in the repo named by |
| 227 | `[ci] deploy_repo`, anvil POSTs JSON to `[ci] deploy_webhook`: |
| 228 | |
| 229 | ```json |
| 230 | { "repo": "collin/anvil", "ref": "main", "commit": "<oid>", "run_id": 42 } |
| 231 | ``` |
| 232 | |
| 233 | No other repo can trigger this, even with passing CI. The webhook target is a |
| 234 | **host-local plaintext** receiver (HTTPS is unsupported, to keep the build |
| 235 | TLS-free) — typically a tiny script-runner on hagrid that, on a verified |
| 236 | request, runs the actual redeploy. Verify the `X-Anvil-Deploy-Secret` header |
| 237 | (set `[ci] deploy_secret`) before doing anything. Because anvil cross-compiles |
| 238 | (section 3), "redeploy anvil" usually means: the receiver pulls the freshly |
| 239 | built image and re-runs `deploy/run.sh` — it does **not** build in-place. |
| 240 | |
| 241 | **Building an image is not a CI job.** Job containers get no Docker socket by |
| 242 | design, so `docker build`/`docker push` cannot happen inside a pipeline. That |
| 243 | work belongs to a separate deploy agent on the build host, triggered by this |
| 244 | webhook, running *outside* the sandbox with Docker access — a different trust |
| 245 | level from the runner, and deliberately a different process. |
| 246 | |
| 247 | > The deploy receiver runs with whatever privileges you give it — keep it |
| 248 | > minimal, secret-gated, and bound to localhost / the Docker host gateway only. |
| 249 | |
| 250 | ## Operations |
| 251 | |
| 252 | - **Update**: from the Mac, `./deploy/deploy.sh` (build + ship + restart in |
| 253 | one command, one passphrase prompt); or on hagrid, re-run `./deploy/run.sh` |
| 254 | to recreate the container from the already-loaded image (the `anvil-data` |
| 255 | volume persists). NOTE: adding new DB tables in a |
| 256 | future version won't auto-apply to an existing database yet (Toasty migration |
| 257 | support is pending) — the git repos on disk are unaffected, but repo metadata |
| 258 | in SQLite may need recreating until migrations land. |
| 259 | - **Backup**: snapshot the `anvil-data` volume, e.g. |
| 260 | `docker run --rm -v anvil-data:/data -v "$PWD":/out debian:bookworm-slim \ |
| 261 | tar czf /out/anvil-data.tgz -C /data .` |
| 262 | - **Logs**: `docker logs -f anvil`. |
| 263 | - **System routes** live under `/-/` (e.g. sign in at |
| 264 | `https://anvil.richardscollin.com/-/login`); `/{username}` is the user/repo |
| 265 | namespace. |