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 | |||
| 8 | 8 | # Deploy plumbing, not image content: compose.yaml describes how to run the | |
| 9 | 9 | # image and .env holds the host's secrets. Neither belongs inside it. | |
| 10 | 10 | compose.yaml | |
| 11 | + | compose.override.yaml | |
| 11 | 12 | .env | |
| 13 | + | # Bind-mounted by compose.override.yaml, never COPYd (and 225KB). | |
| 14 | + | /deploy/dev-ca.crt | |
modifiedDEPLOY.md+6 −0
| ⋯ 54 unchanged lines | |||
| 55 | 55 | `registry.vibe.richardscollin.com`, copies `compose.yaml` up, then pulls and | |
| 56 | 56 | recreates the container there. Install it once with `~/Code/hagrid/hag install`. | |
| 57 | 57 | ||
| 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 | + | ||
| 58 | 64 | anvil needs one step in front of that, because the image carries a **prebuilt** | |
| 59 | 65 | binary — `deploy/deploy.sh` is the whole deploy: | |
| 60 | 66 | ||
| ⋯ 250 unchanged lines | |||
modifiedREADME.md+21 −4
| ⋯ 28 unchanged lines | |||
| 29 | 29 | `PORT`/`HOST` and `ANVIL_BASE_URL` (or `PORTLESS_URL`) override the listen | |
| 30 | 30 | address and public URL, so a proxy can place anvil without a config file. | |
| 31 | 31 | ||
| 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`. | |
| 36 | 53 | ||
| 37 | 54 | ## Deploying | |
| 38 | 55 | ||
| ⋯ 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 | |||
| 69 | 69 | # owned by the in-container `anvil` user. | |
| 70 | 70 | anvil-data: | |
| 71 | 71 | 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.) | |
| 72 | 75 | ||
| 73 | 76 | # Caddy runs on this network and reverse-proxies to the container by name. | |
| 74 | 77 | # The hagrid repo owns the network's lifecycle. | |
| ⋯ 3 unchanged lines | |||
modifieddeploy/build.sh+17 −4
| ⋯ 6 unchanged lines | |||
| 7 | 7 | # Dockerfile expects it. Building and pushing the image is compose's job from | |
| 8 | 8 | # there (see compose.yaml); ./deploy/deploy.sh runs both halves. | |
| 9 | 9 | # | |
| 10 | + | # ./deploy/build.sh release — what gets deployed | |
| 11 | + | # ./deploy/build.sh --debug unoptimized, for the local compose.override.yaml | |
| 12 | + | # | |
| 10 | 13 | # Prereqs (one-time): | |
| 11 | 14 | # zig (dnf install zig, or brew install zig) | |
| 12 | 15 | # cargo install cargo-zigbuild | |
| ⋯ 1 unchanged line | |||
| 14 | 17 | set -euo pipefail | |
| 15 | 18 | ||
| 16 | 19 | 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 | |
| 17 | 28 | ||
| 18 | 29 | cd "$(dirname "$0")/.." | |
| 19 | 30 | ||
| ⋯ 2 unchanged lines | |||
| 22 | 33 | # process (children inherit it). | |
| 23 | 34 | ulimit -n 4096 2>/dev/null || true | |
| 24 | 35 | ||
| 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[@]}" | |
| 27 | 40 | ||
| 28 | 41 | echo "==> staging binary at deploy/anvild" | |
| 29 | - | cp "target/$TARGET/release/anvild" deploy/anvild | |
| 42 | + | cp "target/$TARGET/$PROFILE/anvild" deploy/anvild | |
| 30 | 43 | ||
| 31 | 44 | # Deliberately left in place: `docker compose build` runs after this and needs | |
| 32 | 45 | # 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" | |