Skip to content

Commit 7ad6b87

Browse files
Merge pull request #119 from ChrisX101010/feat/docker-deployment
Add optional Docker deployment for self-hosting (#115)
2 parents 6bbe533 + f000ba6 commit 7ad6b87

6 files changed

Lines changed: 312 additions & 0 deletions

File tree

‎.dockerignore‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Keep the build context small and deterministic.
2+
# NOTE: do NOT exclude *.md — the book sources are markdown.
3+
4+
.git
5+
.github
6+
.gitignore
7+
8+
# Build output (mirrors .gitignore)
9+
target/
10+
site/
11+
docs/
12+
**/book/
13+
14+
# Editor / OS noise
15+
.vscode/
16+
.idea/
17+
**/*:Zone.Identifier
18+
.DS_Store

‎.github/workflows/docker.yml‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Docker image
2+
3+
# Build-only. Nothing is published, so this adds no release surface or
4+
# registry credentials — it exists so the Dockerfile cannot silently rot.
5+
on:
6+
push:
7+
branches: [main]
8+
paths:
9+
- 'docker/**'
10+
- '.dockerignore'
11+
- 'xtask/**'
12+
- '**/book.toml'
13+
- '.github/workflows/docker.yml'
14+
pull_request:
15+
paths:
16+
- 'docker/**'
17+
- '.dockerignore'
18+
- 'xtask/**'
19+
- '**/book.toml'
20+
- '.github/workflows/docker.yml'
21+
workflow_dispatch:
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
build:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
32+
- uses: docker/setup-buildx-action@v3
33+
34+
- name: Build image
35+
uses: docker/build-push-action@v6
36+
with:
37+
context: .
38+
file: docker/Dockerfile
39+
push: false
40+
load: true
41+
tags: rust-training:ci
42+
cache-from: type=gha
43+
cache-to: type=gha,mode=max
44+
45+
- name: Smoke test
46+
run: |
47+
docker run -d --name books -p 3000:8080 rust-training:ci
48+
for i in $(seq 1 30); do
49+
if curl -fsS http://localhost:3000/ >/dev/null 2>&1; then ok=1; break; fi
50+
sleep 2
51+
done
52+
if [ "${ok:-0}" != "1" ]; then
53+
echo "::error::server did not come up"; docker logs books; exit 1
54+
fi
55+
# Landing page and at least one book must resolve.
56+
# NB: match the <title>; the <h1> is split by a <span> tag.
57+
curl -fsS http://localhost:3000/ | grep -q "<title>Rust Training Books</title>"
58+
curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:3000/async-book/ | grep -q 200
59+
# Extensionless links must resolve via try_files.
60+
curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:3000/async-book/ch00-introduction | grep -q 200
61+
docker rm -f books

‎docker/Dockerfile‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# syntax=docker/dockerfile:1
2+
#
3+
# Multi-stage build for the RustTraining book collection.
4+
#
5+
# Stage 1 (builder) — Rust toolchain + mdbook + mdbook-mermaid, runs
6+
# `cargo xtask build` to produce site/.
7+
# Stage 2 (runtime) — unprivileged nginx serving the static output.
8+
# No Rust, no mdbook, no source in the final image.
9+
#
10+
# Build from the REPOSITORY ROOT, not from docker/:
11+
# docker build -f docker/Dockerfile -t rust-training .
12+
13+
ARG RUST_VERSION=1
14+
ARG MDBOOK_VERSION=0.4.52
15+
ARG MDBOOK_MERMAID_VERSION=0.14.0
16+
ARG NGINX_VERSION=1.27
17+
18+
# ──────────────────────────────────────────────────────────────────────
19+
# Stage 1: build the books
20+
# ──────────────────────────────────────────────────────────────────────
21+
FROM rust:${RUST_VERSION}-slim-bookworm AS builder
22+
23+
ARG MDBOOK_VERSION
24+
ARG MDBOOK_MERMAID_VERSION
25+
ARG TARGETARCH
26+
27+
WORKDIR /build
28+
29+
RUN apt-get update \
30+
&& apt-get install -y --no-install-recommends ca-certificates curl \
31+
&& rm -rf /var/lib/apt/lists/*
32+
33+
# Prefer prebuilt release binaries — `cargo install mdbook mdbook-mermaid`
34+
# compiles both from source and adds several minutes to every cold build.
35+
#
36+
# Upstream does not ship a complete set of prebuilt targets, so we fall back
37+
# to compiling when an asset is missing:
38+
# - mdbook has linux-gnu on amd64, linux-musl on arm64
39+
# - mdbook-mermaid has NO published arm64 Linux binary at all
40+
# musl builds are statically linked and run fine on this glibc base image.
41+
RUN set -eux; \
42+
case "${TARGETARCH:-amd64}" in \
43+
amd64) mdbook_triple=x86_64-unknown-linux-gnu; mermaid_triple=x86_64-unknown-linux-gnu ;; \
44+
arm64) mdbook_triple=aarch64-unknown-linux-musl; mermaid_triple=aarch64-unknown-linux-musl ;; \
45+
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
46+
esac; \
47+
\
48+
install_tool() { \
49+
bin="$1"; url="$2"; crate="$3"; ver="$4"; \
50+
if curl -fsSL "$url" 2>/dev/null | tar -xz -C /usr/local/bin "$bin" 2>/dev/null; then \
51+
echo "==> installed $bin from prebuilt binary"; \
52+
else \
53+
echo "==> no prebuilt $bin for ${TARGETARCH}, compiling from source"; \
54+
cargo install "$crate" --version "$ver" --locked --root /usr/local; \
55+
fi; \
56+
}; \
57+
\
58+
install_tool mdbook \
59+
"https://github.com/rust-lang/mdBook/releases/download/v${MDBOOK_VERSION}/mdbook-v${MDBOOK_VERSION}-${mdbook_triple}.tar.gz" \
60+
mdbook "${MDBOOK_VERSION}"; \
61+
install_tool mdbook-mermaid \
62+
"https://github.com/badboy/mdbook-mermaid/releases/download/v${MDBOOK_MERMAID_VERSION}/mdbook-mermaid-v${MDBOOK_MERMAID_VERSION}-${mermaid_triple}.tar.gz" \
63+
mdbook-mermaid "${MDBOOK_MERMAID_VERSION}"; \
64+
\
65+
mdbook --version; \
66+
mdbook-mermaid --version
67+
68+
# Copy the whole workspace. xtask resolves the project root from
69+
# CARGO_MANIFEST_DIR at compile time, so it must be built and run in place.
70+
COPY . .
71+
72+
# `cargo xtask build` writes to site/ (the deploy target, docs/, is for
73+
# GitHub Pages and additionally prints commit instructions we don't want here).
74+
RUN --mount=type=cache,target=/usr/local/cargo/registry \
75+
--mount=type=cache,target=/build/target \
76+
cargo run --release --package xtask -- build \
77+
&& test -f site/index.html \
78+
&& echo "==> built $(find site -mindepth 1 -maxdepth 1 -type d | wc -l) books"
79+
80+
# ──────────────────────────────────────────────────────────────────────
81+
# Stage 2: serve
82+
# ──────────────────────────────────────────────────────────────────────
83+
# nginx-unprivileged is the stock nginx image reconfigured to run as uid 101
84+
# and listen on 8080, so the container needs no root and no NET_BIND_SERVICE.
85+
FROM nginxinc/nginx-unprivileged:${NGINX_VERSION}-alpine AS runtime
86+
87+
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
88+
COPY --from=builder --chown=nginx:nginx /build/site /usr/share/nginx/html
89+
90+
EXPOSE 8080
91+
92+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
93+
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1

‎docker/README.md‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Containerized deployment
2+
3+
Optional, opt-in way to self-host the book collection without GitHub Pages —
4+
useful behind a firewall or on an internal network.
5+
6+
**This is not the local development path.** For writing and previewing, use
7+
`cargo xtask serve`, which rebuilds and serves at <http://localhost:3000> with
8+
no container involved.
9+
10+
## Usage
11+
12+
From the repository root:
13+
14+
```bash
15+
docker compose -f docker/compose.yaml up --build
16+
```
17+
18+
Then open <http://localhost:3000>. Override the host port with `PORT`:
19+
20+
```bash
21+
PORT=8080 docker compose -f docker/compose.yaml up --build
22+
```
23+
24+
Without Compose:
25+
26+
```bash
27+
docker build -f docker/Dockerfile -t rust-training .
28+
docker run --rm -p 3000:8080 rust-training
29+
```
30+
31+
Note the build context is the repository root in both cases — the build needs
32+
the book sources and the `xtask` crate.
33+
34+
## How it works
35+
36+
Two stages:
37+
38+
1. **builder** (`rust:1-slim-bookworm`) installs `mdbook` and `mdbook-mermaid`,
39+
then runs `cargo xtask build`, which builds all seven books into `site/`
40+
along with the generated landing page.
41+
2. **runtime** (`nginxinc/nginx-unprivileged:alpine`) serves `site/` on port
42+
8080. No Rust toolchain, no mdbook, no book sources in the final image.
43+
44+
`xtask build` is used rather than `xtask deploy` because the two produce
45+
identical content — `deploy` only differs in writing to `docs/` and printing
46+
GitHub Pages instructions, which are irrelevant in a container.
47+
48+
## Pinned versions
49+
50+
`MDBOOK_VERSION` and `MDBOOK_MERMAID_VERSION` are build args in the Dockerfile.
51+
CI (`pages.yml`) currently installs both unpinned via `cargo install`, so the
52+
container may lag or lead the published site after an upstream mdbook release.
53+
Bump the args when that matters.
54+
55+
Prebuilt release binaries are used where upstream publishes them, falling back
56+
to `cargo install` otherwise. As of the pinned versions, `mdbook-mermaid` has no
57+
published arm64 Linux binary, so arm64 builds compile it from source and take
58+
noticeably longer.
59+
60+
## Notes
61+
62+
- The container runs as uid 101 and binds an unprivileged port, so it needs no
63+
root and no added capabilities.
64+
- Adding `read_only: true` to the service is possible but requires tmpfs mounts
65+
for nginx's cache and pid paths; it is left off by default rather than shipped
66+
untested.
67+
- Content is baked in at build time. Rebuild the image to pick up book changes.

‎docker/compose.yaml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: rust-training
2+
3+
services:
4+
books:
5+
build:
6+
# Context is the repo root: the build needs the book sources and the
7+
# xtask crate, which live above this file.
8+
context: ..
9+
dockerfile: docker/Dockerfile
10+
image: rust-training:local
11+
container_name: rust-training-books
12+
13+
# Host port is configurable; the container always listens on 8080 because
14+
# nginx-unprivileged runs as a non-root user.
15+
ports:
16+
- "${PORT:-3000}:8080"
17+
18+
restart: unless-stopped
19+
20+
security_opt:
21+
- no-new-privileges:true
22+
23+
healthcheck:
24+
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"]
25+
interval: 30s
26+
timeout: 5s
27+
start_period: 5s
28+
retries: 3

‎docker/nginx.conf‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
server {
2+
listen 8080;
3+
server_name _;
4+
5+
root /usr/share/nginx/html;
6+
index index.html;
7+
8+
# mdbook emits real .html files, but internal and hand-written links
9+
# sometimes omit the extension. Resolving both keeps parity with the
10+
# GitHub Pages behaviour.
11+
#
12+
# NOTE: add_header is NOT inherited into a block that declares its own
13+
# add_header, so every location repeats the headers it needs rather than
14+
# relying on a server-level declaration.
15+
location / {
16+
try_files $uri $uri/ $uri.html =404;
17+
18+
add_header Cache-Control "no-cache" always;
19+
add_header X-Content-Type-Options "nosniff" always;
20+
add_header X-Frame-Options "SAMEORIGIN" always;
21+
add_header Referrer-Policy "no-referrer" always;
22+
}
23+
24+
# mdbook's assets (book.js, ace.js, css) are NOT content-hashed — the same
25+
# URL serves new bytes after a rebuild — so they must not be marked
26+
# immutable. A short expiry keeps them cheap without pinning stale JS in
27+
# browsers that never revalidate.
28+
location ~* \.(css|js|woff2?|ttf|svg|png|jpe?g|gif|ico)$ {
29+
expires 1d;
30+
31+
add_header X-Content-Type-Options "nosniff" always;
32+
add_header X-Frame-Options "SAMEORIGIN" always;
33+
add_header Referrer-Policy "no-referrer" always;
34+
}
35+
36+
gzip on;
37+
gzip_vary on;
38+
gzip_min_length 1024;
39+
gzip_proxied any;
40+
gzip_types text/plain text/css application/javascript application/json
41+
image/svg+xml font/woff font/woff2;
42+
43+
access_log /dev/stdout;
44+
error_log /dev/stderr warn;
45+
}

0 commit comments

Comments
 (0)