mkdocs is a static site generator that I use for several documentation sites both public and private. Up until recently I've relied on Github actions to automate deployments to VPS hosts (eschewing Github pages).

Todays post will discuss the steps required to deploy a fully self-hosted, automatically updating documentation stack using Gitea, mkdocs, nginx, traefik and Drone CI.

Pre-requisites

For the purposes of this guide we should assume you are familiar with the basics of DNS. However we'll need to make sure that you have created a record which points your chosen domain to the IP of the system running traefik and mkdocs.

There are lots of ways to achieve this end result but the way that I do it is by hosting the DNS for domain on Cloudflare. I give Traefik my Cloudflare API key which it uses to verify my ownership of the domain in question via dnsChallenge and once successful automatically generates the required TLS certificates.

For a full overview of everything related to Traefik see my other site where I wrote up a getting started "Traefik 101" type post at perfectmediaserver.com/remote-access/traefik101.html.

As CI/CD is a fairly advanced topic I assume familiarity with docker, docker-compose and managing that stack. I've written (as have others) about it many times before - for example.

Overview

First, let's take a look at a picture attempting to explain what we're trying to put together.

From now on, whatever you push to git will get built and deployed automatically. There are lots of nuances to this approach and if you're doing this in production you might wish to read into promotions to control what appears in which environment and when.

Next, let's take a look at the docker-compose.yml file needed in its entirety (it's quite long but we're defining traefik, gitea, drone, drone-docker-runner, and the nginx container running the wiki itself all in one file):

---
version: "2"
services:
  traefik:
    image: traefik
    container_name: tr
    volumes:
      - /home/alex/appdata/traefik/letsencrypt:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - 80:80
      - 443:443
    environment:
      - [email protected]
      - CLOUDFLARE_API_KEY=CFglobalAPIkey
    command:
      - --providers.docker=true
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entryPoint.to=websecure
      - --entrypoints.web.http.redirections.entryPoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.cloudflare.acme.dnschallenge=true
      - --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare
      - [email protected]
      - --certificatesresolvers.cloudflare.acme.storage=/letsencrypt/acme.json
###
  gitea:
    image: gitea/gitea
    container_name: gitea
    volumes:
      - /opt/appdata/gitea:/data
    labels:
      - traefik.http.routers.git.rule=Host(`git.ktz.me`)
      - traefik.http.routers.git.tls.certresolver=cloudflare
    ports:
      - "2222:2222"
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - ROOT_URL=https://git.ktz.me
      - SSH_DOMAIN=git.ktz.me
      - APP_NAME=git.ktz.me
      - SSH_PORT=2222
      - DISABLE_REGISTRATION=true
      - REQUIRE_SIGNIN_VIEW=true
    depends_on:
      - mysql
    restart: unless-stopped
###
  drone:
    image: drone/drone:latest
    container_name: drone
    labels:
      - traefik.http.routers.drone.rule=Host(`drone.m.wd.ktz.me`)
      - traefik.http.routers.drone.tls.certresolver=cloudflare
    environment:
      - DRONE_GITEA_SERVER=https://git.ktz.me/
      - DRONE_GIT_ALWAYS_AUTH=true
      - DRONE_GITEA_CLIENT_ID=1234
      - DRONE_GITEA_CLIENT_SECRET=1234
      - DRONE_SERVER_HOST=drone.m.wd.ktz.me
      - DRONE_SERVER_PROTO=https
      - DRONE_RPC_SECRET=super-duper-rpc-secret
      - DRONE_USER_CREATE=username:alex,admin:true
    restart: unless-stopped
  drone-runner-docker:
    image: drone/drone-runner-docker:1
    container_name: drone-runner-docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - DRONE_RPC_PROTO=https
      - DRONE_RPC_HOST=drone.m.wd.ktz.me
      - DRONE_RPC_SECRET=super-duper-rpc-secret
      - DRONE_RUNNER_CAPACITY=2
      - DRONE_RUNNER_NAME=whatsinaname
    restart: unless-stopped
###
  mkdocswiki:
    image: nginx
    container_name: mkdocswiki
    volumes:
      - /opt/appdata/mkdocswiki/site:/usr/share/nginx/html:ro
    labels:
      - traefik.http.routers.wellandwiki.rule=Host(`wiki.domain.com`)
      - traefik.http.routers.wellandwiki.tls.certresolver=cloudflare
    restart: unless-stopped
docker-compose.yaml

If you're struggling to find some of these values then review the Gitea configuration section coming up shortly.

For some reason, Drone CI recommend against running Gitea and Drone on the same instance especially when using docker-compose due to "network complications". We're using Traefik and all the routing is handled internally or via DNS, so there are no port conflicts.

Whatever Drone's reasoning for this disclaimer is, laziness or otherwise, you should be fine to configure your system using the one-file docker-compose approach outlined above. In my case I actually wanted everything co-located on one node so that drone-docker-runner had access to the filesystem for spitting out my statically generated mkdocs site to disk.

Gitea configuration

The drone CI documentation does a good job of providing an overview of the configuration you need to undertake in Gitea.

Create an OAuth Application -> Create a Shared Secret with openssl -> Start Drone CI (as a docker container in our case) -> Start some runners

Next, obviously, you will need an mkdocs repository. If you don't have one ready, you can use the repo behind perfectmediaserver.com as an example.

Configure a build

We can finally move on to actually configuring a build now that we have everything up and running.

---
kind: pipeline
type: docker
name: build
steps:
- name: build
  image: squidfunk/mkdocs-material:7.1.9
  volumes:
  - name: site
    path: /site
  commands:
  - pip install -U -r ./requirements.txt
  - mkdocs build
  - cp -r site/ /site
  - chmod -R 777 /site
volumes:
- name: site
  host:
    path: /opt/appdata/mkdocswiki
.drone.yml

This file lives in the root of your Git repo and tells Drone what to do. We're using the squidfunk/mkdocs-material:7.1.9 docker image (it is automatically pulled from docker hub by Drone) and performing the build entirely within the context of that container. No dependencies or other mess is put onto the host system which is clean AF and really nice.

The only real gotcha here is to make sure that the volume path matches that of the nginx container you're using to serve the wiki itself - /opt/appdata/mkdocswiki in this case.

Connecting Gitea and Drone

When you first launch Drone in your browser you will be greeted with the following screen.

Once you click continue you will be asked to authenticate with Gitea and then will be automatically redirected back to the Drone interface below.

Drone dashboard

The dashboard of Drone is quite pretty to look at but is also pretty functional. Click SYNC in the top right to ensure that the list of repos in Drone is synchronised with Gitea. Once sync'd, use the filter to find the repo you're interested in and activate it by clicking on it.

Trusted build

You'll need to ensure you enable Trusted in the settings page for the build so that the container can access host volumes.

What happens if you don't give the Trusted permission to the build

After this, we can trigger build either by performing a commit + push to your repository with git or by clicking + NEW BUILD.

Like most CI history graphs, there are plenty of failures as you tinker and improve stuff. For this reason I have created a dedicated Drone CI testing repo - in my case to test Ansible plays.

Running an Ansible Playbook

This is a bit more advanced as it makes use of secrets in Drone. These are configured in the web interface but the file below should give you a good idea of what's possible outside of just a simple static site deployment.

The Ansible plugin for Drone is documented here.

kind: pipeline
name: default

steps:
- name: check ansible syntax
  image: plugins/ansible:3
  settings:
    playbook: run.yaml
    galaxy: requirements.yaml
    inventory: hosts.ini
    vault_password:
      from_secret: ansible_vault_password
    syntax_check: true
  when:
    event:
    - push

- name: apply ansible playbook
  image: plugins/ansible:3
  # environment:
  #   additional_var:
  #     from_secret: additional_var
  #   another_var: foo
  settings:
    playbook: run.yaml
    galaxy: requirements.yaml
    inventory: hosts.ini
    private_key:
      from_secret: ansible_private_key
    vault_password:
      from_secret: ansible_vault_password
  when:
    event:
    - push
    - tag