| 1 | # syntax=docker/dockerfile:1 |
| 2 | # |
| 3 | # Both images anvil ships, from one builder. |
| 4 | # |
| 5 | # --target anvil the forge (compose.yaml) — this is the default |
| 6 | # --target worker a CI runner, LOCAL DEVELOPMENT ONLY (compose.override.yaml) |
| 7 | # |
| 8 | # The build stage runs on the BUILD host's architecture and cross-compiles to |
| 9 | # x86_64-musl with cargo-zigbuild, so an arm64 workstation produces the amd64 |
| 10 | # image hagrid runs without emulating rustc. That is what deploy/build.sh used |
| 11 | # to do outside Docker; doing it here means the build context is the only |
| 12 | # input, so `hag deploy` is the whole deploy and there is no staged binary |
| 13 | # left lying around to go stale. |
| 14 | # |
| 15 | # The VPS still compiles nothing — it only ever pulls the finished image. |
| 16 | # |
| 17 | # The two runtime stages share the builder, so `docker compose build` compiles |
| 18 | # the workspace once and takes two binaries out of it. |
| 19 | |
| 20 | # ---------------------------------------------------------------- builder -- |
| 21 | |
| 22 | # --platform=$BUILDPLATFORM: compile natively, cross-link to the target. Rust |
| 23 | # comes from rust-toolchain.toml below, not from this tag, which is only a |
| 24 | # rustup to bootstrap from. |
| 25 | FROM --platform=$BUILDPLATFORM rust:1-bookworm AS builder |
| 26 | |
| 27 | # zig is the cross-linker cargo-zigbuild drives. Pinned with a checksum: this |
| 28 | # is a toolchain fetched over the network into the build that produces the |
| 29 | # internet-facing binary. Both digests come from ziglang.org/download/index.json |
| 30 | # and have to be bumped together with ZIG_VERSION. |
| 31 | ARG ZIG_VERSION=0.16.0 |
| 32 | ARG ZIG_SHA256_amd64=70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 |
| 33 | ARG ZIG_SHA256_arm64=ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17 |
| 34 | ARG BUILDARCH |
| 35 | RUN set -eux; \ |
| 36 | case "$BUILDARCH" in \ |
| 37 | amd64) zig_arch=x86_64; zig_sha="$ZIG_SHA256_amd64" ;; \ |
| 38 | arm64) zig_arch=aarch64; zig_sha="$ZIG_SHA256_arm64" ;; \ |
| 39 | *) echo "no pinned zig for BUILDARCH=$BUILDARCH" >&2; exit 1 ;; \ |
| 40 | esac; \ |
| 41 | url="https://ziglang.org/download/${ZIG_VERSION}/zig-${zig_arch}-linux-${ZIG_VERSION}.tar.xz"; \ |
| 42 | curl -fsSL "$url" -o /tmp/zig.tar.xz; \ |
| 43 | echo "${zig_sha} /tmp/zig.tar.xz" | sha256sum -c -; \ |
| 44 | mkdir -p /opt/zig; \ |
| 45 | tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1; \ |
| 46 | rm /tmp/zig.tar.xz; \ |
| 47 | ln -s /opt/zig/zig /usr/local/bin/zig; \ |
| 48 | zig version |
| 49 | |
| 50 | # Installed before rust-toolchain.toml lands, so it builds with the image's own |
| 51 | # toolchain rather than dragging the pinned one in early. --locked so this |
| 52 | # layer is reproducible too. |
| 53 | ARG CARGO_ZIGBUILD_VERSION=0.23.0 |
| 54 | RUN cargo install cargo-zigbuild --version "$CARGO_ZIGBUILD_VERSION" --locked |
| 55 | |
| 56 | WORKDIR /app |
| 57 | |
| 58 | # rust-toolchain.toml on its own, ahead of the sources: rustup materializes the |
| 59 | # pinned channel and the musl target in a layer that only rebuilds when the pin |
| 60 | # changes. It stays the ONE place the Rust version is set (see CLAUDE.md) — |
| 61 | # nothing here names a version. |
| 62 | COPY rust-toolchain.toml ./ |
| 63 | RUN rustup show |
| 64 | |
| 65 | COPY Cargo.toml Cargo.lock ./ |
| 66 | COPY crates ./crates |
| 67 | COPY vendor ./vendor |
| 68 | |
| 69 | # Release unless a caller asks otherwise; compose.override.yaml passes debug so |
| 70 | # the local loop is not paying for optimization. |
| 71 | ARG PROFILE=release |
| 72 | ARG TARGET=x86_64-unknown-linux-musl |
| 73 | |
| 74 | # Cache mounts rather than the dummy-crate trick: the registry and target dirs |
| 75 | # survive between builds, so editing one crate recompiles that crate and not |
| 76 | # 536 dependencies. `sharing=locked` because compose builds the two runtime |
| 77 | # stages concurrently and they share one target dir. |
| 78 | # |
| 79 | # Both binaries come out of a single cargo invocation, and they have to be |
| 80 | # copied OUT of the target mount inside the same RUN — a cache mount is not |
| 81 | # part of the layer, so a later COPY --from could not see them. |
| 82 | RUN --mount=type=cache,id=anvil-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ |
| 83 | --mount=type=cache,id=anvil-cargo-target,target=/app/target,sharing=locked \ |
| 84 | set -eux; \ |
| 85 | case "$PROFILE" in \ |
| 86 | release) flags="--release" ;; \ |
| 87 | debug) flags= ;; \ |
| 88 | *) echo "PROFILE must be release or debug, got '$PROFILE'" >&2; exit 1 ;; \ |
| 89 | esac; \ |
| 90 | cargo zigbuild --locked --target "$TARGET" --bin anvild --bin anvil-worker $flags; \ |
| 91 | mkdir -p /out; \ |
| 92 | cp "target/${TARGET}/${PROFILE}/anvild" "target/${TARGET}/${PROFILE}/anvil-worker" /out/ |
| 93 | |
| 94 | # ------------------------------------------------------------------ anvil -- |
| 95 | |
| 96 | # Pinned to amd64 rather than inherited from TARGETPLATFORM: the binaries above |
| 97 | # are always x86_64-musl, so the base they ride on must not follow an arm64 |
| 98 | # workstation. compose.yaml's `platforms:` says the same thing one level up. |
| 99 | FROM --platform=linux/amd64 ubuntu:26.04 AS anvil |
| 100 | |
| 101 | # No git in the image: anvil is pure gitoxide (see CLAUDE.md), including the |
| 102 | # push-mirroring client. |
| 103 | RUN apt-get update \ |
| 104 | && apt-get install -y --no-install-recommends ca-certificates \ |
| 105 | && rm -rf /var/lib/apt/lists/* \ |
| 106 | && useradd --system --user-group --home-dir /data anvil \ |
| 107 | && mkdir -p /data /etc/anvil \ |
| 108 | && chown -R anvil:anvil /data |
| 109 | |
| 110 | COPY --from=builder /out/anvild /usr/local/bin/anvild |
| 111 | |
| 112 | # Which baked config to ship: production's by default, the committed local one |
| 113 | # when compose.override.yaml or deploy/dev.sh builds the image. |
| 114 | ARG CONFIG=deploy/anvil.toml |
| 115 | COPY ${CONFIG} /etc/anvil/anvil.toml |
| 116 | |
| 117 | EXPOSE 3000 2222 |
| 118 | VOLUME /data |
| 119 | USER anvil |
| 120 | |
| 121 | ENTRYPOINT ["/usr/local/bin/anvild"] |
| 122 | CMD ["-c", "/etc/anvil/anvil.toml", "serve"] |
| 123 | |
| 124 | # ----------------------------------------------------------------- worker -- |
| 125 | |
| 126 | # LOCAL DEVELOPMENT ONLY. |
| 127 | # |
| 128 | # Production runners are native processes on their own host (a launchd agent on |
| 129 | # the Mac mini, see ../docs/remote-runners.md § Isolation on macOS; the plist |
| 130 | # for it stays in deploy/worker/, which is not a Docker artifact). This stage |
| 131 | # exists so compose.override.yaml can bring up two runners next to the local |
| 132 | # forge and exercise concurrency and platform routing without a second machine. |
| 133 | # Do not deploy it: a containerized runner needs the host's Docker socket |
| 134 | # mounted in, which is the root-equivalent hold that moving CI off the forge was |
| 135 | # meant to remove. That trade is already made locally — the dev compose file |
| 136 | # mounts the same socket into anvil for agent sessions. |
| 137 | FROM --platform=linux/amd64 ubuntu:26.04 AS worker |
| 138 | |
| 139 | # ca-certificates so the claim loop can talk to an https:// forge. The local one |
| 140 | # is plain http over the compose network, but the image should not be the reason |
| 141 | # a runner cannot reach a real instance. |
| 142 | RUN apt-get update \ |
| 143 | && apt-get install -y --no-install-recommends ca-certificates \ |
| 144 | && rm -rf /var/lib/apt/lists/* \ |
| 145 | && useradd --system --user-group --home-dir /nonexistent worker |
| 146 | |
| 147 | COPY --from=builder /out/anvil-worker /usr/local/bin/anvil-worker |
| 148 | |
| 149 | # Unprivileged in the container; compose grants the docker group separately |
| 150 | # (`group_add`), which is the only host access the runner needs. |
| 151 | USER worker |
| 152 | |
| 153 | ENTRYPOINT ["/usr/local/bin/anvil-worker"] |