# htmx-ssr runtime image. Disjoint layer graph from Dockerfile.leptos-hydration: no # wasm32 target, no wasm-bindgen, no wasm-opt, no cargo-leptos, no node/css # stage. The server renders the full body server-side; interactivity is htmx # attributes + /api/htmx/* endpoints, so the image bakes only the binary. # # Base: lamina/rust (rust-slim + sccache + cargo-chef + just + mold/clang) — NOT # lamina/leptos/lamina/rustelo. The rustelo pre-cook layer buys nothing here: # the framework source is COPY'd in regardless (path dep), so cargo-chef cooks # the full recipe (rustelo + website deps) over the thin rust base. # # Content-agnostic: NO site/ baked in, and unlike the leptos image there is no # WASM pkg/ either. The entire site tree (content/, config/, i18n/, templates/, # public/ incl. freshly-built styles/website.css, rbac.ncl) is delivered to the # PV at /var/www/site/ by the publish tool. website.css is built during distro # assembly (just distro / content.nu run uno), never inside this image. # # Build arguments — supplied via --build-arg from lian-build/build_directives.ncl. # RUST_VERSION lamina/rust tag # NICKEL_VERSION lamina/nickel tag (runtime nickel + build.rs NCL eval) # LAMINA_REGISTRY lamina catalog registry base # BIN_NAME [[bin]] name in crates/server/Cargo.toml # BIN_FEATURES full feature expression for the SSR build (e.g. "htmx-ssr") # SERVER_PORT port the server listens on (EXPOSE + LEPTOS_SITE_ADDR) ARG RUST_VERSION ARG NICKEL_VERSION ARG LAMINA_REGISTRY ARG BIN_NAME ARG BIN_FEATURES ARG SERVER_PORT # Named alias — BuildKit does not expand ARGs inside --from=; named stage required. # No --platform override: nickel is invoked both by build.rs (native, in-build, # must match the current target arch) and copied into the runtime image (same # arch again) — $BUILDPLATFORM pinned it to the buildkit worker's own arch # instead, which breaks when one worker native-builds a non-native target arch # (e.g. lian-02 serving both amd64 and arm64 claims). FROM ${LAMINA_REGISTRY}/nickel:${NICKEL_VERSION} AS nickel-bin # ─── Stage 0: cargo-chef planner ───────────────────────────────────────────── FROM ${LAMINA_REGISTRY}/rust:${RUST_VERSION} AS planner WORKDIR /workspace ENV CARGO_TARGET_DIR=/workspace/website/target COPY Cargo.toml Cargo.lock ./website/ COPY crates/ ./website/crates/ COPY rustelo/ /rustelo/ COPY stratumiops/ /stratumiops/ WORKDIR /workspace/website RUN cargo chef prepare --recipe-path recipe.json # ─── Stage 1: build (deps cook + SSR binary) ───────────────────────────────── FROM ${LAMINA_REGISTRY}/rust:${RUST_VERSION} AS builder # Pin target dir so the runtime-stage COPY path is stable (lamina/rust defaults # to /cargo-install-target). ENV CARGO_TARGET_DIR=/workspace/website/target COPY --from=nickel-bin /usr/local/bin/nickel /usr/local/bin/nickel WORKDIR /workspace/website COPY --from=planner /workspace/website/recipe.json recipe.json COPY rustelo/ /rustelo/ COPY stratumiops/ /stratumiops/ # Cook deps for the exact feature set the binary is built with — no WASM target, # so a single native cook covers everything (contrast: the leptos image cooks # ssr deps separately because cargo-leptos handles the wasm32 pass). # sccache DISABLED TEMPORARILY (2026-07-06): the S3 backend (Garage, libre-wuji) # is not yet reachable from the fleet's buildkit runners — cross-network routing # unresolved. RUSTC_WRAPPER unset, builds run uncached until that's fixed. When # re-enabling, keep SCCACHE_S3_KEY_PREFIX="${TARGETARCH}/" — this is a native # (non-cross-compiled) multi-arch build, so cache entries must stay namespaced # per architecture. ARG BIN_FEATURES ARG TARGETARCH # Original (sccache-enabled) — restore once Garage is reachable: # RUN --mount=type=secret,id=sccache_env,required \ # --mount=type=cache,target=/workspace/website/target \ # --mount=type=cache,target=/usr/local/cargo/registry \ # set -a && . /run/secrets/sccache_env && set +a && \ # export RUSTC_WRAPPER=sccache && \ # export SCCACHE_S3_KEY_PREFIX="${TARGETARCH}/" && \ # RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold" \ # cargo chef cook --release --no-default-features --features "${BIN_FEATURES}" \ # --recipe-path recipe.json && \ # sccache --show-stats RUN --mount=type=cache,target=/workspace/website/target,id=cargo-target-${TARGETARCH} \ --mount=type=cache,target=/usr/local/cargo/registry \ RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold" \ cargo chef cook --release --no-default-features --features "${BIN_FEATURES}" \ --recipe-path recipe.json # Full sources for the actual build. COPY Cargo.toml Cargo.lock ./ COPY crates/ ./crates/ # ManifestResolver (rustelo_utils) walks up from CARGO_MANIFEST_DIR for this. COPY rustelo.manifest.toml ./rustelo.manifest.toml # build.rs (build_page_generator) evaluates site/config NCL during compilation. COPY site/config/ ./site/config/ COPY site/rbac.ncl ./site/rbac.ncl ARG BIN_NAME # build.rs reads SITE_CONFIG_PATH; nickel resolves framework contracts via # NICKEL_IMPORT_PATH (rustelo layout is resources/nickel/, not nickel/). ENV SITE_CONFIG_PATH=/workspace/website/site/config/index.ncl ENV NICKEL_IMPORT_PATH=/rustelo/code/resources/nickel:/workspace/website/site/config # Original (sccache-enabled) — restore once Garage is reachable: # RUN --mount=type=secret,id=sccache_env,required \ # --mount=type=cache,target=/workspace/website/target \ # --mount=type=cache,target=/usr/local/cargo/registry \ # set -a && . /run/secrets/sccache_env && set +a && \ # export RUSTC_WRAPPER=sccache && \ # export SCCACHE_S3_KEY_PREFIX="${TARGETARCH}/" && \ # sccache --start-server && \ # RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold" \ # cargo build --release -p rustelo-htmx-server --no-default-features --features "${BIN_FEATURES}" && \ # cp /workspace/website/target/release/${BIN_NAME} /tmp/${BIN_NAME} && \ # CARGO_TARGET_DIR=/workspace/website/target \ # cargo build --release -p rustelo_content_graph_ssr --features gen \ # --manifest-path /rustelo/code/crates/foundation/crates/rustelo_content_graph_ssr/Cargo.toml && \ # cp /workspace/website/target/release/gen-content-graph /tmp/gen-content-graph && \ # sccache --show-stats RUN --mount=type=cache,target=/workspace/website/target,id=cargo-target-${TARGETARCH} \ --mount=type=cache,target=/usr/local/cargo/registry \ RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold" \ cargo build --release -p rustelo-htmx-server --no-default-features --features "${BIN_FEATURES}" && \ cp /workspace/website/target/release/${BIN_NAME} /tmp/${BIN_NAME} && \ CARGO_TARGET_DIR=/workspace/website/target \ cargo build --release -p rustelo_content_graph_ssr --features gen \ --manifest-path /rustelo/code/crates/foundation/crates/rustelo_content_graph_ssr/Cargo.toml && \ cp /workspace/website/target/release/gen-content-graph /tmp/gen-content-graph # ─── Stage 2: runtime ───────────────────────────────────────────────────────── FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates libssl3 curl \ && rm -rf /var/lib/apt/lists/* RUN useradd -m -u 1000 www ARG BIN_NAME ARG SERVER_PORT # Server binary → /usr/local/bin/ so data PV mounts never shadow it. COPY --from=builder /tmp/${BIN_NAME} /usr/local/bin/${BIN_NAME} # Content-graph generator: regenerates content_graph.json from site/content + ontoref # without a rebuild. Run: gen-content-graph [out.json] (needs ONTOREF_NICKEL_IMPORT_PATH). COPY --from=builder /tmp/gen-content-graph /usr/local/bin/gen-content-graph # nickel required at runtime: server calls `nickel export` to parse site/config/*.ncl # on startup and site/rbac.ncl on RBAC hot-reload. COPY --from=nickel-bin /usr/local/bin/nickel /usr/local/bin/nickel # Rustelo framework nickel contracts — required by site/config/*.ncl at runtime. COPY --from=builder /rustelo/code/resources/nickel/ /usr/local/share/rustelo/nickel/ WORKDIR /var/www # Content-agnostic image: NO site/ baked in, NO WASM pkg/. The site tree # (public/ incl. styles/website.css, content/, config/, i18n/, templates/, # rbac.ncl) is delivered to the PV mounted at /var/www/site/ by the publish # tool. server.ncl public_dir = "site/public" resolves identically in dev # (source tree) and prod (PV-delivered tree). The htmx shell loads its runtime # from public/ (htmx.min.js, extensions, reinit handlers) — all PV-served. # The PV mount must be populated before first pod start; the K8s securityContext # fsGroup=1000 (catalog deployment template) handles ownership at mount. USER www EXPOSE ${SERVER_PORT} ENV RUST_LOG=info \ LEPTOS_ENV=PROD \ LEPTOS_SITE_ADDR=0.0.0.0:${SERVER_PORT} \ LEPTOS_SITE_ROOT=site \ SITE_CONFIG_PATH=/var/www/site/config/index.ncl \ NICKEL_IMPORT_PATH=/usr/local/share/rustelo/nickel:/var/www/site/config \ SITE_SERVER_CONTENT_URL=/r \ SITE_SERVER_ROOT_CONTENT=r \ SITE_SERVER_CONTENT_ROOT=site/r \ NATS_ADMIN_CREDENTIALS_FILE= HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -sf http://localhost:${SERVER_PORT}/health > /dev/null || exit 1 # Entrypoint: wait for the PV-delivered site tree before exec'ing the server. # Image is content-agnostic; site/config/index.ncl must exist for the binary to # boot. While the PV is empty (first install, before publish-tool delivery), # this keeps the container alive and logs a clear "waiting" message instead of # crash-looping. Signals propagate via exec when the server takes over. COPY --chmod=755 <<'EOF' /usr/local/bin/entrypoint.sh #!/bin/sh set -eu CONFIG="${SITE_CONFIG_PATH:-/var/www/site/config/index.ncl}" while [ ! -f "$CONFIG" ]; do printf '[%s] site config missing at %s — waiting for PV bootstrap (publish tool to deliver site tarball)\n' \ "$(date -Iseconds)" "$CONFIG" >&2 sleep 10 done printf '[%s] site config found at %s — starting server\n' "$(date -Iseconds)" "$CONFIG" >&2 exec /usr/local/bin/rustelo-htmx-server "$@" EOF CMD ["/usr/local/bin/entrypoint.sh"]