anvilsign in

collin/anvil · 7ddd780b

Run anvil locally with a compose override

Collin Richards · 2026-08-24 09:41 UTC · 7ddd780b72873d9441d2fef674cea14061aee161 · parent 60a55cb5 · browse files

modified.dockerignore+3 −0
⋯ 7 unchanged lines
88 # Deploy plumbing, not image content: compose.yaml describes how to run the
99 # image and .env holds the host's secrets. Neither belongs inside it.
1010 compose.yaml
11+compose.override.yaml
1112 .env
13+# Bind-mounted by compose.override.yaml, never COPYd (and 225KB).
14+/deploy/dev-ca.crt
modifiedDEPLOY.md+6 −0
⋯ 54 unchanged lines
5555 `registry.vibe.richardscollin.com`, copies `compose.yaml` up, then pulls and
5656 recreates the container there. Install it once with `~/Code/hagrid/hag install`.
5757
58+`compose.override.yaml` sits next to it for local development — loopback ports,
59+the dev config, the Docker socket agent sessions need. Compose merges it
60+automatically when you run `docker compose` in the repo root, and `hag` always
61+passes `-f compose.yaml` explicitly, so none of it ever reaches hagrid. See the
62+README for the local loop.
63+
5864 anvil needs one step in front of that, because the image carries a **prebuilt**
5965 binary — `deploy/deploy.sh` is the whole deploy:
6066
⋯ 250 unchanged lines
modifiedREADME.md+21 −4
⋯ 28 unchanged lines
2929 `PORT`/`HOST` and `ANVIL_BASE_URL` (or `PORTLESS_URL`) override the listen
3030 address and public URL, so a proxy can place anvil without a config file.
3131
32-To run it the way it runs in production — in Docker, with CI able to reach the
33-host's Docker socket — use `./deploy/dev.sh`, which builds the image, starts a
34-container, and (with [portless](https://www.npmjs.com/package/portless)) serves
35-it over HTTPS at `https://anvil.localhost`.
32+To run it in Docker the way it runs in production, `compose.override.yaml`
33+layers local settings over the deployment file and compose merges it
34+automatically:
35+
36+```sh
37+./deploy/build.sh --debug # stage the binary the image COPYs in
38+docker compose up -d --build # then http://127.0.0.1:20640
39+docker compose logs -f
40+docker compose down
41+```
42+
43+The override swaps in `deploy/anvil.dev.toml` (so agent sessions are on and the
44+SSO issuer is `https://login.localhost`), publishes to loopback instead of the
45+droplet's public IP, uses the `anvil-dev-data` volume, and mounts the Docker
46+socket that agent sessions need. `hag` passes `-f compose.yaml` explicitly, so
47+none of it reaches the host.
48+
49+`./deploy/dev.sh` is the fuller path and still there: it also generates the
50+`deploy/dev-ca.crt` bundle the override mounts, waits for `/-/healthz`, and
51+points [portless](https://www.npmjs.com/package/portless) at the container for
52+`https://anvil.localhost`.
3653
3754 ## Deploying
3855
⋯ 23 unchanged lines
addedcompose.override.yaml+91 −0
1+# Local development only. `docker compose` merges this automatically when it
2+# sits next to compose.yaml, and `hag` always passes `-f compose.yaml`
3+# explicitly, so none of it reaches hagrid -- where Caddy fronts the container,
4+# the SSO issuer is a public HTTPS URL with a normal CA, and there is
5+# deliberately no Docker socket.
6+#
7+# The image carries a prebuilt binary, so stage one first (--debug compiles in
8+# a fraction of the time a release build takes):
9+#
10+# ./deploy/build.sh --debug
11+# docker compose up -d --build
12+#
13+# Then http://127.0.0.1:20640. `docker compose logs -f`, `docker compose down`.
14+#
15+# deploy/dev.sh remains the fuller path: it also generates deploy/dev-ca.crt
16+# (mounted below), waits for /-/healthz, and points portless at the container.
17+services:
18+ anvil:
19+ # A distinct tag, so a local build carrying the DEV config can never be
20+ # mistaken for -- or pushed as -- the production image.
21+ image: anvil-dev:latest
22+ build:
23+ args:
24+ # Bakes deploy/anvil.dev.toml at /etc/anvil/anvil.toml instead of
25+ # production's: local base_url, the login.localhost issuer, agent
26+ # sessions on, and shorter periodic scans.
27+ CONFIG: deploy/anvil.dev.toml
28+ # Not `anvil`: that name belongs to deploy/dev.sh's container, and compose
29+ # refuses to adopt a container it did not label.
30+ container_name: anvil-dev
31+
32+ # !override, not a merge: `ports` is one of the keys compose CONCATENATES,
33+ # so without it the production entry survives and the container tries to
34+ # bind 165.232.162.167:22 on this machine.
35+ ports: !override
36+ # A stable, collision-resistant port for this project (`devport`), so it
37+ # does not wander between runs.
38+ - "127.0.0.1:${ANVIL_DEV_PORT:-20640}:3000"
39+ # anvil.dev.toml advertises 20641 in SSH clone URLs; keep the two in step.
40+ - "127.0.0.1:${ANVIL_DEV_SSH_PORT:-20641}:2222"
41+
42+ volumes: !override
43+ # Separate from production's `anvil-data`, and the same volume dev.sh
44+ # uses, so the two local paths share state. `docker volume rm
45+ # anvil-dev-data` starts over.
46+ - anvil-dev-data:/data
47+
48+ # Agent sessions (`[agent] enabled = true` in anvil.dev.toml) drive
49+ # Docker directly, so they need the socket. CI does NOT -- that moved to
50+ # anvil-worker, which is its own Docker client on its own machine.
51+ #
52+ # `label=disable` below rather than a `:z` relabel: :z would rewrite the
53+ # SELinux label on the HOST's socket, which every other container on this
54+ # machine also uses.
55+ - /var/run/docker.sock:/var/run/docker.sock
56+
57+ # The SSO back channel calls https://login.localhost directly, and that
58+ # certificate comes from the CA portless generated. anvild ships its own
59+ # root store (rustls), so `portless trust` does not reach it -- hence a
60+ # bundle of the host's roots plus that CA, which SSL_CERT_FILE points at.
61+ # deploy/dev.sh writes this file; regenerate it by hand with:
62+ # cat /etc/ssl/certs/ca-bundle.crt ~/.portless/ca.pem > deploy/dev-ca.crt
63+ - ./deploy/dev-ca.crt:/etc/ssl/certs/anvil-dev-ca.crt:ro,z
64+
65+ # The gid owning /var/run/docker.sock on this host. `stat -c '%g'
66+ # /var/run/docker.sock` if it differs on yours.
67+ group_add:
68+ - "${DOCKER_GID:-970}"
69+ security_opt:
70+ - label=disable
71+
72+ # Appended to production's host.docker.internal entry, not replacing it:
73+ # login.localhost resolves to the container's own loopback otherwise,
74+ # rather than the host's portless proxy.
75+ extra_hosts:
76+ - "login.localhost:host-gateway"
77+
78+ environment:
79+ SSL_CERT_FILE: /etc/ssl/certs/anvil-dev-ca.crt
80+ # Overrides anvil.dev.toml's baked base_url. Point it at
81+ # http://127.0.0.1:20640 when testing websockets -- portless proxies
82+ # them over HTTP/2, where they are currently broken -- but note that
83+ # changing it also changes the OIDC redirect_uri, which the provider
84+ # matches exactly.
85+ ANVIL_BASE_URL: ${ANVIL_BASE_URL:-https://anvil.localhost}
86+
87+volumes:
88+ anvil-dev-data:
89+ name: anvil-dev-data
90+ # Same expected "not created by Docker Compose" warning as production's
91+ # volume: dev.sh made this one first, and compose adopts it.
modifiedcompose.yaml+3 −0
⋯ 68 unchanged lines
6969 # owned by the in-container `anvil` user.
7070 anvil-data:
7171 name: anvil-data
72+ # (Compose warns that it did not create this volume, then adopts it. That
73+ # is the intended path off the `docker run` deploys; `external: true`
74+ # would silence it but break a first install on a fresh host.)
7275
7376 # Caddy runs on this network and reverse-proxies to the container by name.
7477 # The hagrid repo owns the network's lifecycle.
⋯ 3 unchanged lines
modifieddeploy/build.sh+17 −4
⋯ 6 unchanged lines
77 # Dockerfile expects it. Building and pushing the image is compose's job from
88 # there (see compose.yaml); ./deploy/deploy.sh runs both halves.
99 #
10+# ./deploy/build.sh release — what gets deployed
11+# ./deploy/build.sh --debug unoptimized, for the local compose.override.yaml
12+#
1013 # Prereqs (one-time):
1114 # zig (dnf install zig, or brew install zig)
1215 # cargo install cargo-zigbuild
⋯ 1 unchanged line
1417 set -euo pipefail
1518
1619 TARGET="x86_64-unknown-linux-musl"
20+PROFILE=release
21+CARGO_FLAGS=(--release)
22+
23+case "${1:-}" in
24+ --debug) PROFILE=debug; CARGO_FLAGS=() ;;
25+ "") ;;
26+ *) echo "usage: $0 [--debug]" >&2; exit 64 ;;
27+esac
1728
1829 cd "$(dirname "$0")/.."
1930
⋯ 2 unchanged lines
2233 # process (children inherit it).
2334 ulimit -n 4096 2>/dev/null || true
2435
25-echo "==> cross-compiling anvild for $TARGET (native, via zig)"
26-cargo zigbuild --release --target "$TARGET" --bin anvild
36+# Static musl even for --debug: the runtime image is Ubuntu, and a binary
37+# linked against Fedora's glibc would not run there.
38+echo "==> cross-compiling anvild for $TARGET ($PROFILE, native, via zig)"
39+cargo zigbuild --target "$TARGET" --bin anvild "${CARGO_FLAGS[@]}"
2740
2841 echo "==> staging binary at deploy/anvild"
29-cp "target/$TARGET/release/anvild" deploy/anvild
42+cp "target/$TARGET/$PROFILE/anvild" deploy/anvild
3043
3144 # Deliberately left in place: `docker compose build` runs after this and needs
3245 # it in the context. It is gitignored, and the next build overwrites it.
33-echo "==> staged. Build the image with: docker compose build (or ./deploy/deploy.sh)"
46+echo "==> staged ($PROFILE). Build the image with: docker compose build"