diff --git a/.dockerignore b/.dockerignore
new file mode 100755
index 0000000..652c688
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+# Keep the build context small and deterministic.
+# NOTE: do NOT exclude *.md — the book sources are markdown.
+
+.git
+.github
+.gitignore
+
+# Build output (mirrors .gitignore)
+target/
+site/
+docs/
+**/book/
+
+# Editor / OS noise
+.vscode/
+.idea/
+**/*:Zone.Identifier
+.DS_Store
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100755
index 0000000..5bd4a9d
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,61 @@
+name: Docker image
+
+# Build-only. Nothing is published, so this adds no release surface or
+# registry credentials — it exists so the Dockerfile cannot silently rot.
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'docker/**'
+ - '.dockerignore'
+ - 'xtask/**'
+ - '**/book.toml'
+ - '.github/workflows/docker.yml'
+ pull_request:
+ paths:
+ - 'docker/**'
+ - '.dockerignore'
+ - 'xtask/**'
+ - '**/book.toml'
+ - '.github/workflows/docker.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Build image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: docker/Dockerfile
+ push: false
+ load: true
+ tags: rust-training:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Smoke test
+ run: |
+ docker run -d --name books -p 3000:8080 rust-training:ci
+ for i in $(seq 1 30); do
+ if curl -fsS http://localhost:3000/ >/dev/null 2>&1; then ok=1; break; fi
+ sleep 2
+ done
+ if [ "${ok:-0}" != "1" ]; then
+ echo "::error::server did not come up"; docker logs books; exit 1
+ fi
+ # Landing page and at least one book must resolve.
+ # NB: match the
; the is split by a tag.
+ curl -fsS http://localhost:3000/ | grep -q "Rust Training Books"
+ curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:3000/async-book/ | grep -q 200
+ # Extensionless links must resolve via try_files.
+ curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:3000/async-book/ch00-introduction | grep -q 200
+ docker rm -f books
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100755
index 0000000..9e5f9f1
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,93 @@
+# syntax=docker/dockerfile:1
+#
+# Multi-stage build for the RustTraining book collection.
+#
+# Stage 1 (builder) — Rust toolchain + mdbook + mdbook-mermaid, runs
+# `cargo xtask build` to produce site/.
+# Stage 2 (runtime) — unprivileged nginx serving the static output.
+# No Rust, no mdbook, no source in the final image.
+#
+# Build from the REPOSITORY ROOT, not from docker/:
+# docker build -f docker/Dockerfile -t rust-training .
+
+ARG RUST_VERSION=1
+ARG MDBOOK_VERSION=0.4.52
+ARG MDBOOK_MERMAID_VERSION=0.14.0
+ARG NGINX_VERSION=1.27
+
+# ──────────────────────────────────────────────────────────────────────
+# Stage 1: build the books
+# ──────────────────────────────────────────────────────────────────────
+FROM rust:${RUST_VERSION}-slim-bookworm AS builder
+
+ARG MDBOOK_VERSION
+ARG MDBOOK_MERMAID_VERSION
+ARG TARGETARCH
+
+WORKDIR /build
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Prefer prebuilt release binaries — `cargo install mdbook mdbook-mermaid`
+# compiles both from source and adds several minutes to every cold build.
+#
+# Upstream does not ship a complete set of prebuilt targets, so we fall back
+# to compiling when an asset is missing:
+# - mdbook has linux-gnu on amd64, linux-musl on arm64
+# - mdbook-mermaid has NO published arm64 Linux binary at all
+# musl builds are statically linked and run fine on this glibc base image.
+RUN set -eux; \
+ case "${TARGETARCH:-amd64}" in \
+ amd64) mdbook_triple=x86_64-unknown-linux-gnu; mermaid_triple=x86_64-unknown-linux-gnu ;; \
+ arm64) mdbook_triple=aarch64-unknown-linux-musl; mermaid_triple=aarch64-unknown-linux-musl ;; \
+ *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
+ esac; \
+ \
+ install_tool() { \
+ bin="$1"; url="$2"; crate="$3"; ver="$4"; \
+ if curl -fsSL "$url" 2>/dev/null | tar -xz -C /usr/local/bin "$bin" 2>/dev/null; then \
+ echo "==> installed $bin from prebuilt binary"; \
+ else \
+ echo "==> no prebuilt $bin for ${TARGETARCH}, compiling from source"; \
+ cargo install "$crate" --version "$ver" --locked --root /usr/local; \
+ fi; \
+ }; \
+ \
+ install_tool mdbook \
+ "https://github.com/rust-lang/mdBook/releases/download/v${MDBOOK_VERSION}/mdbook-v${MDBOOK_VERSION}-${mdbook_triple}.tar.gz" \
+ mdbook "${MDBOOK_VERSION}"; \
+ install_tool mdbook-mermaid \
+ "https://github.com/badboy/mdbook-mermaid/releases/download/v${MDBOOK_MERMAID_VERSION}/mdbook-mermaid-v${MDBOOK_MERMAID_VERSION}-${mermaid_triple}.tar.gz" \
+ mdbook-mermaid "${MDBOOK_MERMAID_VERSION}"; \
+ \
+ mdbook --version; \
+ mdbook-mermaid --version
+
+# Copy the whole workspace. xtask resolves the project root from
+# CARGO_MANIFEST_DIR at compile time, so it must be built and run in place.
+COPY . .
+
+# `cargo xtask build` writes to site/ (the deploy target, docs/, is for
+# GitHub Pages and additionally prints commit instructions we don't want here).
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+ --mount=type=cache,target=/build/target \
+ cargo run --release --package xtask -- build \
+ && test -f site/index.html \
+ && echo "==> built $(find site -mindepth 1 -maxdepth 1 -type d | wc -l) books"
+
+# ──────────────────────────────────────────────────────────────────────
+# Stage 2: serve
+# ──────────────────────────────────────────────────────────────────────
+# nginx-unprivileged is the stock nginx image reconfigured to run as uid 101
+# and listen on 8080, so the container needs no root and no NET_BIND_SERVICE.
+FROM nginxinc/nginx-unprivileged:${NGINX_VERSION}-alpine AS runtime
+
+COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=builder --chown=nginx:nginx /build/site /usr/share/nginx/html
+
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+ CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..f1f4c52
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,67 @@
+# Containerized deployment
+
+Optional, opt-in way to self-host the book collection without GitHub Pages —
+useful behind a firewall or on an internal network.
+
+**This is not the local development path.** For writing and previewing, use
+`cargo xtask serve`, which rebuilds and serves at with
+no container involved.
+
+## Usage
+
+From the repository root:
+
+```bash
+docker compose -f docker/compose.yaml up --build
+```
+
+Then open . Override the host port with `PORT`:
+
+```bash
+PORT=8080 docker compose -f docker/compose.yaml up --build
+```
+
+Without Compose:
+
+```bash
+docker build -f docker/Dockerfile -t rust-training .
+docker run --rm -p 3000:8080 rust-training
+```
+
+Note the build context is the repository root in both cases — the build needs
+the book sources and the `xtask` crate.
+
+## How it works
+
+Two stages:
+
+1. **builder** (`rust:1-slim-bookworm`) installs `mdbook` and `mdbook-mermaid`,
+ then runs `cargo xtask build`, which builds all seven books into `site/`
+ along with the generated landing page.
+2. **runtime** (`nginxinc/nginx-unprivileged:alpine`) serves `site/` on port
+ 8080. No Rust toolchain, no mdbook, no book sources in the final image.
+
+`xtask build` is used rather than `xtask deploy` because the two produce
+identical content — `deploy` only differs in writing to `docs/` and printing
+GitHub Pages instructions, which are irrelevant in a container.
+
+## Pinned versions
+
+`MDBOOK_VERSION` and `MDBOOK_MERMAID_VERSION` are build args in the Dockerfile.
+CI (`pages.yml`) currently installs both unpinned via `cargo install`, so the
+container may lag or lead the published site after an upstream mdbook release.
+Bump the args when that matters.
+
+Prebuilt release binaries are used where upstream publishes them, falling back
+to `cargo install` otherwise. As of the pinned versions, `mdbook-mermaid` has no
+published arm64 Linux binary, so arm64 builds compile it from source and take
+noticeably longer.
+
+## Notes
+
+- The container runs as uid 101 and binds an unprivileged port, so it needs no
+ root and no added capabilities.
+- Adding `read_only: true` to the service is possible but requires tmpfs mounts
+ for nginx's cache and pid paths; it is left off by default rather than shipped
+ untested.
+- Content is baked in at build time. Rebuild the image to pick up book changes.
diff --git a/docker/compose.yaml b/docker/compose.yaml
new file mode 100755
index 0000000..dc5c804
--- /dev/null
+++ b/docker/compose.yaml
@@ -0,0 +1,28 @@
+name: rust-training
+
+services:
+ books:
+ build:
+ # Context is the repo root: the build needs the book sources and the
+ # xtask crate, which live above this file.
+ context: ..
+ dockerfile: docker/Dockerfile
+ image: rust-training:local
+ container_name: rust-training-books
+
+ # Host port is configurable; the container always listens on 8080 because
+ # nginx-unprivileged runs as a non-root user.
+ ports:
+ - "${PORT:-3000}:8080"
+
+ restart: unless-stopped
+
+ security_opt:
+ - no-new-privileges:true
+
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"]
+ interval: 30s
+ timeout: 5s
+ start_period: 5s
+ retries: 3
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100755
index 0000000..7a8b9d7
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,45 @@
+server {
+ listen 8080;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # mdbook emits real .html files, but internal and hand-written links
+ # sometimes omit the extension. Resolving both keeps parity with the
+ # GitHub Pages behaviour.
+ #
+ # NOTE: add_header is NOT inherited into a block that declares its own
+ # add_header, so every location repeats the headers it needs rather than
+ # relying on a server-level declaration.
+ location / {
+ try_files $uri $uri/ $uri.html =404;
+
+ add_header Cache-Control "no-cache" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "no-referrer" always;
+ }
+
+ # mdbook's assets (book.js, ace.js, css) are NOT content-hashed — the same
+ # URL serves new bytes after a rebuild — so they must not be marked
+ # immutable. A short expiry keeps them cheap without pinning stale JS in
+ # browsers that never revalidate.
+ location ~* \.(css|js|woff2?|ttf|svg|png|jpe?g|gif|ico)$ {
+ expires 1d;
+
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "no-referrer" always;
+ }
+
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_proxied any;
+ gzip_types text/plain text/css application/javascript application/json
+ image/svg+xml font/woff font/woff2;
+
+ access_log /dev/stdout;
+ error_log /dev/stderr warn;
+}