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
:3000inside the container. Caddy (on thehagridDocker 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 toanvil.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:
cargo zigbuild --release --target x86_64-unknown-linux-musl— cross-compiles a fully staticx86_64-musl binary natively (~2 min, no emulation),- stages it at
deploy/anvildand builds a thin image that justCOPYs it in (theDockerfiledoes no compilation — fast), - 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 reachanvil: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-containeranviluser.- 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 fromdeploy/anvil.toml; readdocs/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.shto recreate the container from the already-loaded image (theanvil-datavolume 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-datavolume, 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 athttps://anvil.richardscollin.com/-/login);/{username}is the user/repo namespace.