anvilsign in

collin/anvil

RenderedSource

Deploying anvil on hagrid

anvil runs as a single Docker container at anvil.richardscollin.com, fronted by hagrid's Caddy reverse proxy.

  • Web — anvil listens on :3000 inside the container. Caddy (on the hagrid Docker network) reverse-proxies to it and provides HTTPS via Let's Encrypt. The web port is not published to the host.
  • SSH — Caddy only fronts HTTP(S), so anvil's SSH server is published directly to the host on :2222. Git-over-SSH connects to anvil.richardscollin.com:2222.
  • Runtime — fully self-contained: SQLite and the SSH crypto are compiled in, and gix is pure-Rust. No git, OpenSSH, or system sqlite in the image.

1. DNS

Add an A/AAAA (or CNAME to hagrid) record:

anvil.richardscollin.com  ->  <hagrid's public IP>

This one record covers both the web (443, via Caddy) and SSH (2222, direct to the host).

2. Caddy + index (in the hagrid repo)

In ~/Code/hagrid/Caddyfile, add:

anvil.richardscollin.com {
	reverse_proxy anvil:3000
}

In ~/Code/hagrid/sites.yaml, add an entry:

- name: anvil
  host: anvil.richardscollin.com

Then reload Caddy (./hagrid.sh reload, or ./hagrid.sh deploy to push to the host). Caddy resolves anvil:3000 by container name over the hagrid network, so the anvil container must join that network (the run script does this).

3. Build the image on your Mac, ship it to hagrid

One-shot: ./deploy/deploy.sh does this whole section and section 4 in one go — it opens a single multiplexed SSH connection to hagrid (so the key passphrase is asked at most once), runs build.sh over it, then pipes run.sh to the host to restart the container. The rest of this section describes the individual steps it composes.

Do not build on the VPS — a release build needs ~2–4 GB peak and OOMs a cheap, swap-less droplet. Instead, cross-compile a static binary on your Mac (native speed, no QEMU) and copy it into a thin image.

One-time toolchain setup:

brew install zig
cargo install cargo-zigbuild
rustup target add x86_64-unknown-linux-musl

Then, from a checkout of this repo on your Mac:

./deploy/build.sh

That:

  1. cargo zigbuild --release --target x86_64-unknown-linux-musl — cross-compiles a fully static x86_64-musl binary natively (~2 min, no emulation),
  2. stages it at deploy/anvild and builds a thin image that just COPYs it in (the Dockerfile does no compilation — fast),
  3. ships it: docker save | gzip | ssh hagrid 'docker load'.

The VPS never compiles anything.

4. Run the container on hagrid

On the hagrid host (only runs docker — no build):

./deploy/run.sh

which does:

docker run -d --name anvil --network hagrid --restart unless-stopped \
    -p 2222:2222 \
    -v anvil-data:/data \
    anvil:latest
  • --network hagrid — so Caddy can reach anvil:3000.
  • -p 2222:2222 — publishes SSH to the host.
  • -v anvil-data:/data — a named volume holding the SQLite DB, the bare repos, and the persistent SSH host key. Use a named volume (not a host bind mount) so it's owned by the in-container anvil user.
  • No Docker socket. anvil does not execute CI — runners dial in and run jobs on their own daemons (see docs/remote-runners.md). The container has no reason to reach Docker, so the mount is gone, and with it the root-equivalent hold the internet-facing process used to have on the host.

Agent sessions are the exception. They still drive Docker locally, so turning them on means putting the socket back (ANVIL_DOCKER_SOCK=/var/run/docker.sock ./deploy/run.sh) and accepting that the container again has root-equivalent control of the host. They are off by default and absent from deploy/anvil.toml; read docs/untrusted-mode.md §7 before changing that.

The baked config lives at /etc/anvil/anvil.toml (see deploy/anvil.toml). Override it by bind-mounting your own file over that path.

If you must build on the VPS anyway (not recommended): give it swap and cap parallelism, or it will OOM —

sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile \
  && sudo mkswap /swapfile && sudo swapon /swapfile   # persist in /etc/fstab
# then build with CARGO_BUILD_JOBS=1 (slow, but survives 1 GB RAM)

5. First run: create your account, key, and the repo

# admin user
docker exec anvil anvild -c /etc/anvil/anvil.toml \
    user create collin --email you@example.com --password '<password>' --admin

# your SSH public key (so you can push over SSH)
docker exec -i anvil anvild -c /etc/anvil/anvil.toml \
    user add-key collin --title laptop --key "$(cat ~/.ssh/id_ed25519.pub)"

# the anvil repo itself
docker exec anvil anvild -c /etc/anvil/anvil.toml repo create collin/anvil \
    --description "a minimal git forge in Rust"

(You can also create the user, add keys, and create repos from the web UI once signed in — the CLI is just convenient for the first admin.)

6. Self-host anvil on anvil

From your local anvil checkout:

git remote add origin ssh://git@anvil.richardscollin.com:2222/collin/anvil.git
git push -u origin main

Then browse it at https://anvil.richardscollin.com/collin/anvil. HTTPS clone also works: git clone https://anvil.richardscollin.com/collin/anvil.git (pushes over HTTPS require your account password as the git password).

7. CI: start a runner

anvil dispatches CI; it does not execute it. Nothing runs until a runner dials in, so this is a required step, not an optional one — see docs/remote-runners.md.

Set a shared secret in the config ([ci] runner_token), then on a machine with room to build — the Mac mini, not the droplet:

cargo build --release --bin anvil-worker
cp target/release/anvil-worker /usr/local/bin/

anvil-worker --url https://anvil.richardscollin.com \
             --token "$ANVIL_RUNNER_TOKEN" --name macmini

deploy/worker/com.anvil.worker.plist runs it under launchd on macOS. Run it natively there rather than in a container: a containerized runner needs the Docker socket mounted into it, which rebuilds the hole section 4 just removed.

Isolation is not weaker for being on a Mac. Docker Desktop runs every container inside one Linux VM, so --cap-drop=ALL, no-new-privileges and the cgroup limits are enforced by the same kernel primitives as on Linux — with the VM as an extra boundary a bare-metal Linux host does not have.

Architecture. The Mac is arm64 and hagrid is x86_64, so a job running cargo test on the runner tests an architecture you do not ship. Images are unaffected (deploy/build.sh already cross-compiles and builds --platform linux/amd64), and per-pipeline platform selection is designed but not yet wired to a config key.

8. The redeploy webhook (CD)

Any repo with a .anvil/ci.yml runs CI on push. A pipeline is just an image plus steps:

image: rust:1.95-bookworm
steps:
  - name: test
    run: cargo test --workspace
  - run: cargo build --release

Runs show up at /{owner}/{repo}/ci, with a per-commit status badge on the commit list and a full log on each run's page.

Job sandbox. The runner is the only Docker client on its machine; the job container gets no socket, no mounts (the checkout is uploaded as a tar), all capabilities dropped, and no-new-privileges. Resource bounds come from the forge's [ci] config and travel with each job, so tightening one does not need runners redeployed: memory_mb (default 2048), cpus (2), pids_limit (512), timeout_secs (1800, then the container is killed), network (true), run_as (empty = image default), and allowed_images (empty = any; a tagless entry like "rust" allows every tag). The allowlist is applied on the forge when the job is built, so a runner cannot widen it. See docs/untrusted-mode.md for the threat model and what this does/doesn't protect against.

Secrets now leave the host. A pipeline's secrets are sent to the runner with its job and sit in plaintext in a container on a machine anvil does not own. Scope repository secrets accordingly, and treat a runner host as being as trusted as the forge itself.

Continuous deployment is deliberately scoped to one repository. On a successful run of deploy_branch (default main) in the repo named by [ci] deploy_repo, anvil POSTs JSON to [ci] deploy_webhook:

{ "repo": "collin/anvil", "ref": "main", "commit": "<oid>", "run_id": 42 }

No other repo can trigger this, even with passing CI. The webhook target is a host-local plaintext receiver (HTTPS is unsupported, to keep the build TLS-free) — typically a tiny script-runner on hagrid that, on a verified request, runs the actual redeploy. Verify the X-Anvil-Deploy-Secret header (set [ci] deploy_secret) before doing anything. Because anvil cross-compiles (section 3), "redeploy anvil" usually means: the receiver pulls the freshly built image and re-runs deploy/run.sh — it does not build in-place.

Building an image is not a CI job. Job containers get no Docker socket by design, so docker build/docker push cannot happen inside a pipeline. That work belongs to a separate deploy agent on the build host, triggered by this webhook, running outside the sandbox with Docker access — a different trust level from the runner, and deliberately a different process.

The deploy receiver runs with whatever privileges you give it — keep it minimal, secret-gated, and bound to localhost / the Docker host gateway only.

Operations

  • Update: from the Mac, ./deploy/deploy.sh (build + ship + restart in one command, one passphrase prompt); or on hagrid, re-run ./deploy/run.sh to recreate the container from the already-loaded image (the anvil-data volume persists). NOTE: adding new DB tables in a future version won't auto-apply to an existing database yet (Toasty migration support is pending) — the git repos on disk are unaffected, but repo metadata in SQLite may need recreating until migrations land.
  • Backup: snapshot the anvil-data volume, e.g. docker run --rm -v anvil-data:/data -v "$PWD":/out debian:bookworm-slim \ tar czf /out/anvil-data.tgz -C /data .
  • Logs: docker logs -f anvil.
  • System routes live under /-/ (e.g. sign in at https://anvil.richardscollin.com/-/login); /{username} is the user/repo namespace.