So you want to host the documentation for your project on a plain webserver. Maybe because it’s to be used only internally, or maybe because you don’t trust “the cloud” in the form of GitHub pages (and similar services).

Similarly to a static website, one could package a mkdocs or sphinx website in a container. But what if we want it to re-build automatically? So that you simply push to your git server and everything else would happen magically?

A builder container

So we need a Docker container that 1) would periodically pull a repository, and 2) build it.

In most cases, the repository is private (otherwise, why are you complicating things?), and we would need a public key to pull this repository.

We can build a simple container with a 1 minute cronjob with the following Dockerfile:

FROM python:3.12-slim
RUN apt update && apt install --yes git ssh cron && apt autoclean && rm -rf /var/cache/apt

RUN pip install --no-cache-dir mkdocs
COPY puller.sh /puller.sh
RUN chmod +x /puller.sh

# A cronjob every minute. Output to stdout so it can be seen with `docker logs`
RUN echo "* * * * * root /puller.sh > /proc/1/fd/1 2>/proc/1/fd/2" >> /etc/cron.d/puller

# We need to "trust" github.com (assuming there is where we have our repository!)
# This of course would fail when keys rotate!
RUN ssh-keyscan -t rsa github.com >> ~/.ssh/known_hosts

CMD ["cron", "-f"]

A usual Docker build script would help (assuming you are using a registry, otherwise customize it):

docker build . -t registry:5000/docs-builder:staging
docker push registry:5000/docs-builder:staging

docker tag registry:5000/docs-builder:staging registry:5000/docs-builder:stable
docker push registry:5000/docs-builder:stable

And the magic bit puller.sh:


# (C) 2024 Massimo Girondi massimo@girondi.net

echo "Pulling documentation at $(date --iso-8601=second)"

cd /docs
git config --global --add safe.directory /docs
OLD_HEAD=$(git rev-parse HEAD)
GIT_SSH_COMMAND='ssh -i /docs-builder -o IdentitiesOnly=yes' git fetch --all
git reset --hard origin/main
NEW_HEAD=$(git rev-parse HEAD)

# Rebuild only if the HEAD has changed
if [ "$OLD_HEAD" != "$NEW_HEAD" ]; then
	echo "Head changed: rebuild"
    # Cron typically doesn't include /usr/local/bin in $PATH
	/usr/local/bin/mkdocs build
else
	echo "No change"
fi

echo "Done at $(date --iso-8601=second)"

Note that we are using a specific ssh key for pulling our repo (/docs-builder), since we want to authenticate with ssh (and not with HTTPs). We will mount it as volume when running the container. The public key is then added to the repository as a deploy key in GitHub terms.

Your mileage may vary.

Putting everything together

We can encapsulate everything in a docker compose stack

version: "3"
services:
  server:
    image: nginx:stable-alpine-slim
    volumes:
      - ./docs/www/:/usr/share/nginx/html/:ro
    restart: unless-stopped
    ports:
      - 8001:80

    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 500M

  builder:
    image: registry:50000/docs-builder:stable
    # If you want to build withing docker compose, use the following:
    # build: . 

    volumes:
      - ./docs/:/docs
      - ./docs-builder:/docs-builder:ro
    restart: unless-stopped

    deploy:
      resources:
        limits:
          cpus: '.5'
          memory: 500M

This would assume that your mkdocs.yaml (or equivalent) would build inside ./docs/www, ideally with sources in ./docs/docs, with a folder structure similar to this:

./docker-compose.yaml
./Dockerfile
./build.sh
./puller.sh
./docs-builder       -> the private SSH key 
./docs-builder.pub   -> the public SSH key
./docs/mkdocs.yaml   -> the configuration file for mkdocs
./docs/docs          -> the source folder, with .md files
./docs/www           -> Where your HTML files would be placed