anvilsign in

collin/anvil

main / docker / Dockerfile
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.
25FROM --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.
31ARG ZIG_VERSION=0.16.0
32ARG ZIG_SHA256_amd64=70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00
33ARG ZIG_SHA256_arm64=ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17
34ARG BUILDARCH
35RUN 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.
53ARG CARGO_ZIGBUILD_VERSION=0.23.0
54RUN cargo install cargo-zigbuild --version "$CARGO_ZIGBUILD_VERSION" --locked
55
56WORKDIR /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.
62COPY rust-toolchain.toml ./
63RUN rustup show
64
65COPY Cargo.toml Cargo.lock ./
66COPY crates ./crates
67COPY 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.
71ARG PROFILE=release
72ARG 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.
82RUN --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.
99FROM --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.
103RUN 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
110COPY --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.
114ARG CONFIG=deploy/anvil.toml
115COPY ${CONFIG} /etc/anvil/anvil.toml
116
117EXPOSE 3000 2222
118VOLUME /data
119USER anvil
120
121ENTRYPOINT ["/usr/local/bin/anvild"]
122CMD ["-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.
137FROM --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.
142RUN 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
147COPY --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.
151USER worker
152
153ENTRYPOINT ["/usr/local/bin/anvil-worker"]