Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -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 <title>; the <h1> is split by a <span> tag.
curl -fsS http://localhost:3000/ | grep -q "<title>Rust Training Books</title>"
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
93 changes: 93 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:3000> with
no container involved.

## Usage

From the repository root:

```bash
docker compose -f docker/compose.yaml up --build
```

Then open <http://localhost:3000>. 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.
28 changes: 28 additions & 0 deletions docker/compose.yaml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}