From f000ba6dbe45733929a01846c7231a5b5485888d Mon Sep 17 00:00:00 2001 From: ChrisX001001 Date: Sun, 2 Aug 2026 08:53:53 +0200 Subject: [PATCH] Add optional Docker deployment (closes #115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-stage build: mdbook + mdbook-mermaid compile all seven books via 'cargo xtask build' in a Rust builder stage; an unprivileged nginx stage serves the static output. No Rust toolchain or sources in the final image. Opt-in and self-contained under docker/ — the supported local development path remains 'cargo xtask serve'. Includes a build-only CI job so the Dockerfile cannot silently rot. --- .dockerignore | 18 +++++++ .github/workflows/docker.yml | 61 +++++++++++++++++++++++ docker/Dockerfile | 93 ++++++++++++++++++++++++++++++++++++ docker/README.md | 67 ++++++++++++++++++++++++++ docker/compose.yaml | 28 +++++++++++ docker/nginx.conf | 45 +++++++++++++++++ 6 files changed, 312 insertions(+) create mode 100755 .dockerignore create mode 100755 .github/workflows/docker.yml create mode 100755 docker/Dockerfile create mode 100644 docker/README.md create mode 100755 docker/compose.yaml create mode 100755 docker/nginx.conf 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 <h1> is split by a <span> tag. + curl -fsS http://localhost:3000/ | grep -q "<title>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; +}