Add server and dockerfile references for Rust backends
server.md covers axum entrypoint, graceful shutdown, error
IntoResponse, and Docker-image deploys; dockerfile.md adapts the
quanttype.net cargo-chef + distroless template with rationale on
caching, pinning, and signal handling.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change qyzvktnxonxyumvtolvrqunzsmqrmnzp
commit 60dc09adfb694aff7c7ac0bdecca99691921633d
author Alpha Chen <alpha@kejadlen.dev>
date
parent wwyytsls
diff --git a/ai/skills/rust/dockerfile.md b/ai/skills/rust/dockerfile.md
new file mode 100644
index 00000000..7c1391e0
--- /dev/null
+++ b/ai/skills/rust/dockerfile.md
@@ -0,0 +1,166 @@
+# Rust server Dockerfile
+
+A reusable Dockerfile for Rust HTTP services. Multi-stage build with
+`cargo-chef` for dependency caching, plus a distroless runtime image
+that ships only the compiled binary and its required shared libraries.
+
+*Adapted from <https://quanttype.net/p/perfect-dockerfile-for-rust-backends/>.*
+
+## Why this shape
+
+- **`cargo-chef` separates dep compilation from source compilation.**
+  Touching a `.rs` file invalidates only the source-build layer, not
+  the dependency-build layer. Crate compilation dominates Rust build
+  time; keeping it cached is the difference between sub-second and
+  multi-minute rebuilds.
+- **Distroless `cc` base image.** No shell, no package manager, no
+  root user. Smaller attack surface, smaller image, and security
+  scanners stop flagging the long tail of unrelated CVEs.
+- **Cache mounts** keep `~/.cargo/registry`, `~/.cargo/git`, and
+  `target/` warm across `docker build` invocations.
+
+## Template
+
+```dockerfile
+# syntax=docker/dockerfile:1.7
+
+ARG BINARY_NAME
+
+# 1. Install cargo-chef once, cached.
+FROM rust:1.85-slim-bookworm@sha256:<rust-digest> AS chef
+WORKDIR /build
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git/db \
+    cargo install cargo-chef --locked
+
+# 2. Plan the dependency build from Cargo.toml + Cargo.lock.
+FROM chef AS planner
+COPY . .
+RUN cargo chef prepare --recipe-path recipe.json
+
+# 3. Build dependencies first (cached), then the app.
+FROM chef AS builder
+ARG BINARY_NAME
+COPY --from=planner /build/recipe.json recipe.json
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git/db \
+    --mount=type=cache,target=/build/target \
+    cargo chef cook --release --recipe-path recipe.json
+COPY . .
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git/db \
+    --mount=type=cache,target=/build/target \
+    cargo build --release --bin "$BINARY_NAME" \
+    && cp "target/release/$BINARY_NAME" "/build/$BINARY_NAME"
+
+# 4. Final image — distroless cc, nonroot.
+FROM gcr.io/distroless/cc-debian13:nonroot@sha256:<distroless-digest> AS runtime
+ARG BINARY_NAME
+LABEL org.opencontainers.image.source="https://github.com/<owner>/<repo>"
+LABEL org.opencontainers.image.licenses="<license>"
+COPY --from=builder /build/$BINARY_NAME /usr/local/bin/app
+ENTRYPOINT ["/usr/local/bin/app"]
+```
+
+The runtime stage hard-codes the destination path (`/usr/local/bin/app`)
+and `ENTRYPOINT` — this avoids needing the build arg at runtime and
+keeps `docker run <image>` simple.
+
+## Customization points
+
+- `ARG BINARY_NAME` is required. Pass it as `--build-arg
+  BINARY_NAME=<name>` or set a default in CI.
+- Add `COPY --from=builder /build/<extra>` lines for static assets
+  (templates, migrations, etc.) before the `ENTRYPOINT`.
+- For data volumes, declare empty directories with `RUN ["mkdir",
+  "/data"]` *before* the distroless `USER` change (which `nonroot`
+  applies automatically).
+
+## Pinning policy
+
+Always pin base images by SHA digest. A floating tag means today's
+`rust:1.85-slim-bookworm` is not tomorrow's, and reproducible builds
+quietly stop being reproducible.
+
+```dockerfile
+FROM rust:1.85-slim-bookworm@sha256:abcdef… AS chef
+```
+
+Refresh digests via Renovate, Dependabot, or a weekly bump — pin once
+and let automation maintain the freshness.
+
+Resolve a digest at the command line:
+
+```bash
+docker buildx imagetools inspect rust:1.85-slim-bookworm \
+  --format '{{json .Manifest.Digest}}'
+```
+
+## Signal handling
+
+Distroless has no init process, so PID 1 is your binary. The Linux
+kernel ignores SIGINT/SIGTERM for PID 1 unless the process explicitly
+installs handlers. Three options, in order of preference:
+
+1. **Handle signals in the binary.** `server.md` shows
+   `tokio::signal::ctrl_c()` and `tokio::signal::unix::signal()` for
+   SIGTERM. This is the recommended path for first-party services.
+2. **`docker run --init`** locally. Docker injects `tini` as PID 1.
+3. **Bundle `tini` in the image** for environments that don't pass
+   `--init` (some PaaS runtimes). Pull it from the distroless static
+   image:
+
+   ```dockerfile
+   COPY --from=krallin/tini@sha256:<tini-digest> /tini /tini
+   ENTRYPOINT ["/tini", "--", "/usr/local/bin/app"]
+   ```
+
+Pick (1) by default — fewer moving parts and consistent behavior in
+local, CI, and prod.
+
+## musl / scratch alternative
+
+For a fully static image:
+
+```dockerfile
+FROM rust:1.85-slim-bookworm@sha256:… AS chef
+RUN rustup target add x86_64-unknown-linux-musl
+# ...
+RUN cargo build --release --target x86_64-unknown-linux-musl --bin "$BINARY_NAME"
+
+FROM scratch AS runtime
+COPY --from=builder /build/<name> /app
+ENTRYPOINT ["/app"]
+```
+
+Tradeoffs:
+
+- Smaller image (no glibc), no shared libraries to drift.
+- musl's allocator is slower than glibc's. Drop in `mimalloc` or
+  `jemalloc` if you need the throughput back.
+- TLS via `rustls`, not OpenSSL — system roots are not available.
+  Bundle `webpki-roots` or vendor a root store.
+
+Distroless is the recommended default. `scratch` is opt-in for
+projects that benefit from the size and have validated the runtime
+tradeoffs.
+
+## Common pitfalls
+
+- **Forgetting cache mounts** turns every change into a full
+  recompile. Verify with `docker build --progress=plain` — you should
+  see `CACHED` on the dependency layer when only source files
+  changed.
+- **Skipping `--locked` on `cargo install`** means cargo-chef's own
+  version drifts on rebuild, invalidating its layer.
+- **Building without `--release`** ships a debug binary —
+  10× slower runtime and ~10× larger.
+- **Using `sqlx` macros** without setting `SQLX_OFFLINE=true` and
+  committing `sqlx-data.json` will fail at compile time inside the
+  builder, where there is no database.
+- **Pinning to a tag instead of a digest** breaks reproducibility
+  silently; the build keeps working but the bytes change.
+- **Copying everything before `cargo chef prepare`** invalidates the
+  planner layer on any source change. The recipe is computed from
+  Cargo manifests only — keep `COPY . .` in the planner stage but
+  trust the recipe layer to capture the dep set.
diff --git a/ai/skills/rust/server.md b/ai/skills/rust/server.md
new file mode 100644
index 00000000..6b5e1ada
--- /dev/null
+++ b/ai/skills/rust/server.md
@@ -0,0 +1,226 @@
+# Rust backend services
+
+Project-type-specific guidance for long-running HTTP services. Read
+`scaffolding.md` first for shared project shape, then return here for
+server-specific additions and the deploy workflow.
+
+## Cargo.toml additions
+
+On top of the baseline in `scaffolding.md`:
+
+```toml
+[dependencies]
+axum = "*"
+tower = "*"
+tower-http = { version = "*", features = ["trace", "timeout", "compression-gzip"] }
+
+[dev-dependencies]
+reqwest = { version = "*", features = ["json"] }
+```
+
+- `axum` — minimal HTTP framework on top of `tower` and `hyper`.
+- `tower-http` — common middleware (request tracing, timeouts,
+  compression). Add features as needed; defaults stay light.
+- `reqwest` — used by integration tests to hit the locally bound
+  server. Not a runtime dependency.
+
+Skip `clap` unless the server takes startup flags. Most servers read
+configuration from the environment instead — see *Configuration*
+below.
+
+## Source layout
+
+```
+src/bin/<name>/
+├── main.rs          # Bind, build router, run with graceful shutdown.
+├── config.rs        # Env → typed config struct.
+├── routes.rs        # Router construction.
+└── handlers/        # One module per handler group.
+    └── mod.rs
+```
+
+Domain logic still lives in the library. Handlers translate HTTP
+requests into library calls and library results into HTTP responses.
+
+## Entrypoint pattern
+
+```rust
+// src/bin/<name>/main.rs
+mod config;
+mod handlers;
+mod routes;
+
+use std::net::SocketAddr;
+
+use miette::IntoDiagnostic as _;
+use tokio::net::TcpListener;
+use tokio::signal;
+use tracing_subscriber::{EnvFilter, fmt, prelude::*};
+
+#[tokio::main]
+async fn main() -> miette::Result<()> {
+    miette::set_panic_hook();
+    tracing_subscriber::registry()
+        .with(fmt::layer())
+        .with(EnvFilter::from_default_env())
+        .init();
+
+    let config = config::load()?;
+    let app = routes::build(&config);
+
+    let addr: SocketAddr = config.bind.parse().into_diagnostic()?;
+    let listener = TcpListener::bind(addr).await.into_diagnostic()?;
+    tracing::info!(%addr, "listening");
+
+    axum::serve(listener, app)
+        .with_graceful_shutdown(shutdown_signal())
+        .await
+        .into_diagnostic()?;
+
+    Ok(())
+}
+
+async fn shutdown_signal() {
+    let ctrl_c = async {
+        signal::ctrl_c().await.expect("install ctrl-c handler");
+    };
+
+    #[cfg(unix)]
+    let terminate = async {
+        signal::unix::signal(signal::unix::SignalKind::terminate())
+            .expect("install SIGTERM handler")
+            .recv()
+            .await;
+    };
+
+    #[cfg(not(unix))]
+    let terminate = std::future::pending::<()>();
+
+    tokio::select! {
+        _ = ctrl_c => tracing::info!("received SIGINT, shutting down"),
+        _ = terminate => tracing::info!("received SIGTERM, shutting down"),
+    }
+}
+```
+
+Notes:
+
+- `shutdown_signal()` returns once either SIGINT or SIGTERM fires.
+  `axum::serve(...).with_graceful_shutdown(...)` stops accepting new
+  connections and lets in-flight requests finish.
+- Distroless containers have no init process. Either rely on this
+  in-process signal handling, or wrap the binary in `tini` /
+  `docker run --init`. See `dockerfile.md`.
+
+## Error responses
+
+Domain errors derive `thiserror::Error` and `miette::Diagnostic` (per
+`scaffolding.md`). Add an `IntoResponse` impl in the binary so
+handlers can return `Result<T, AppError>` directly.
+
+```rust
+// src/bin/<name>/handlers/error.rs
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+use serde_json::json;
+
+use my_crate::MyError;
+
+pub struct AppError(MyError);
+
+impl From<MyError> for AppError {
+    fn from(err: MyError) -> Self {
+        Self(err)
+    }
+}
+
+impl IntoResponse for AppError {
+    fn into_response(self) -> Response {
+        let (status, code) = match &self.0 {
+            MyError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
+            MyError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
+            _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
+        };
+
+        // Log the full diagnostic; respond with code + status only.
+        // The diagnostic detail belongs in observability, not in the
+        // HTTP response.
+        tracing::error!(error = ?self.0, "request failed");
+
+        (status, axum::Json(json!({ "code": code }))).into_response()
+    }
+}
+```
+
+The wrapper is intentional — `IntoResponse` cannot be implemented for
+the library's error type from the binary crate (orphan rule), and a
+thin wrapper keeps the mapping in one place.
+
+Body content is intentionally minimal. Diagnostic codes, source
+spans, and help text leak implementation details — they belong in
+logs and traces, not in the HTTP response body.
+
+## Configuration
+
+Punted. Server config conventions are project-dependent. For a quick
+default, use `clap` derive with `env` (same as CLIs) so each setting
+is documented in `--help` and overridable via env var. Revisit when a
+preferred approach is locked in.
+
+## Integration testing
+
+`tests/api.rs` spawns the server on a random port and hits it with
+`reqwest`. This catches wiring issues that unit tests miss (missing
+routes, middleware misconfiguration, response shape).
+
+```rust
+use std::net::SocketAddr;
+
+use reqwest::StatusCode;
+use tokio::net::TcpListener;
+
+async fn spawn() -> SocketAddr {
+    let app = my_crate::routes::test_router();
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    tokio::spawn(async move {
+        axum::serve(listener, app).await.unwrap();
+    });
+    addr
+}
+
+#[tokio::test]
+async fn health_returns_200() {
+    let addr = spawn().await;
+    let res = reqwest::get(format!("http://{addr}/health"))
+        .await
+        .unwrap();
+    assert_eq!(res.status(), StatusCode::OK);
+}
+```
+
+Domain logic lives in the library and is exercised by hegeltest
+property tests (see `property-testing.md`). Integration tests focus
+on HTTP plumbing.
+
+## Deploy
+
+Server release is a Docker image build and push.
+
+1. Build the image — see `dockerfile.md` for the cargo-chef +
+   distroless template.
+2. Tag with the git SHA and a semver / CalVer label.
+3. Push to a registry of your choice (skill stays registry-agnostic).
+4. Roll out via your orchestrator (Kubernetes, Nomad, ECS, …).
+
+Image tags follow this pattern:
+
+```
+ghcr.io/<owner>/<name>:sha-<git-sha>     # immutable
+ghcr.io/<owner>/<name>:<calver>          # human-readable
+ghcr.io/<owner>/<name>:latest            # rolling pointer
+```
+
+CI builds and pushes on every merge to main. The orchestrator pulls
+by sha tag for reproducibility; humans read the calver/latest tags
+when diagnosing.