10 KiB
Purpose
After many years of Material for MKDocs being updated with new features and security updates, it finally reached EOL around the end of 2025. The project maintainers started pivoting to a new successor called Zensical. This document outlines my particular process for setting up a standalone documentation server within a virtual machine.
!!! info "Assumptions"
It is assumed that you are deploying this server into Ubuntu Server 24.04.2 LTS (Minimal). It is also assumed that you are running every command as a user with superuser privileges (e.g. root).
Setup Python Environment
The first thing we need to do is install the necessary python packages and install the zensical software stack inside of it.
sudo apt update && sudo apt upgrade -y
sudo apt install -y nano python3 python3.12-venv
mkdir -p /srv/zensical
cd /srv/zensical
python3 -m venv .venv
source .venv/bin/activate
pip install zensical
zensical new .
deactivate
Configure Zensical Settings
Now we want to set some sensible defaults for Zensical to style it to look as close to Material for MKDocs as possible.
# Scalar Keys
sed -i -E 's/^[[:space:]]*site_name[[:space:]]*=[[:space:]]*".*"/site_name = "Bunny Lab"/' zensical.toml
sed -i -E 's/^[[:space:]]*site_description[[:space:]]*=[[:space:]]*".*"/site_description = "Homelab Knowledgebase"/' zensical.toml
sed -i -E 's/^[[:space:]]*site_author[[:space:]]*=[[:space:]]*".*"/site_author = "Nicole Rappe"/' zensical.toml
sed -i -E 's|^[[:space:]]*#?[[:space:]]*(site_url[[:space:]]*=[[:space:]]*)".*"|\1"https://kb.bunny-lab.io/"|' zensical.toml
# Text inside the copyright triple-quoted string
sed -i -E 's/Copyright[[:space:]]*©[[:space:]]*2026[[:space:]]*The authors/Copyright \© 2026 Bunny Lab/g' zensical.toml
# Theme
sed -i -E 's/^[[:space:]]*#?[[:space:]]*(variant[[:space:]]*=[[:space:]]*)"classic"/\1"classic"/' zensical.toml
# Feature Toggles
sed -i -E 's/^[[:space:]]*#([[:space:]]*"content\.action\.edit",[[:space:]]*)/\1/' zensical.toml
sed -i -E 's/^[[:space:]]*#([[:space:]]*"content\.action\.view",[[:space:]]*)/\1/' zensical.toml
sed -i -E 's/^([[:space:]]*)"navigation\.footer",[[:space:]]*$/#\1"navigation.footer",/' zensical.toml
sed -i -E 's/^[[:space:]]*#[[:space:]]*("navigation\.instant\.progress",[[:space:]]*)/\1/' zensical.toml
sed -i -E 's/^([[:space:]]*)"navigation\.sections",[[:space:]]*$/#\1"navigation.sections",/' zensical.toml
sed -i -E 's/^[[:space:]]*#([[:space:]]*"navigation\.tabs",[[:space:]]*)/\1/' zensical.toml
sed -i -E 's/^[[:space:]]*#([[:space:]]*"navigation\.tabs\.sticky",[[:space:]]*)/\1/' zensical.toml
Deploy NGINX Webserver
We need to deploy NGINX as a webserver, because when using reverse proxies like Traefik, it seems to not get along with Zensical at all. Attempts to resolve this all failed, so putting the statically-built copies of site data that Zensical generates into NGINX's root directory is the second-best solution I came up with. Traefik can be reasonably expected to behave when interacting with NGINX versus Zensical's built-in webserver.
sudo apt install -y nginx
sudo rm -f /etc/nginx/sites-enabled/default
sudo tee /etc/nginx/sites-available/zensical.conf > /dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name _;
root /srv/zensical/site;
index index.html;
# Primary document handling
location / {
try_files $uri $uri/ /index.html;
}
# Static asset caching (safe for docs)
location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
try_files $uri =404;
}
# Prevent access to source or metadata
location ~* \.(toml|md)$ {
deny all;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/zensical.conf /etc/nginx/sites-enabled/zensical.conf
sudo nginx -t
sudo systemctl reload nginx
sudo systemctl enable nginx
Create Zensical Watchdog Service
Since NGINX has taken over hosting the webpages, this does not need to be accessible from other servers, only NGINX itself which runs on the same host as Zensical. We only want to use the zensical serve command to keep a watchdog on the documentation folder and automatically rebuild the static site content when changes are detected. These changes are then served by NGINX's webserver.
# Create Service User, Assign Access, and Lockdown Zensical Data
sudo useradd --system --home /srv/zensical --shell /usr/sbin/nologin zensical || true
sudo chown -R zensical:zensical /srv/zensical
sudo find /srv/zensical -type d -exec chmod 2775 {} \;
sudo find /srv/zensical -type f -exec chmod 664 {} \;
# Make Zensical Binary Executable for Service
sudo chmod +x /srv/zensical/.venv/bin/zensical
# Add Additional User(s) to Folder for Extra Access (Such as Doc Runners)
sudo usermod -aG zensical nicole
# Create Service
sudo tee /etc/systemd/system/zensical-watchdog.service > /dev/null <<'EOF'
[Unit]
Description=Zensical Document Changes Watchdog (zensical serve)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=zensical
Group=zensical
WorkingDirectory=/srv/zensical
# Run the venv binary directly; no activation needed
ExecStart=/srv/zensical/.venv/bin/zensical serve
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
# Start & Enable Automatic Startup of Service
sudo systemctl daemon-reload
sudo systemctl enable --now zensical-watchdog.service
Create Gitea Runner
Now is time for the arguably most-important stage of deployment, which is setting up a Gitea Act Runner. This is how document changes in a Gitea repository will propagate automatically into Zensical's /srv/zensical/docs folder.
# Create dedicated Gitea runner service account
sudo useradd --system --create-home --home /var/lib/gitea_runner --shell /usr/sbin/nologin gitearunner || true
# Allow the runner to write documentation changes
sudo usermod -aG zensical gitearunner
# Download Newest Gitea Runner Binary (https://gitea.com/gitea/act_runner/releases)
cd /tmp
wget https://gitea.com/gitea/act_runner/releases/download/v0.2.13/act_runner-0.2.13-linux-amd64
sudo install -m 0755 act_runner-0.2.13-linux-amd64 /usr/local/bin/gitea_runner
gitea_runner --version
# Generate Gitea Runner Configuration
sudo mkdir -p /etc/gitea_runner
sudo chown gitearunner:gitearunner /etc/gitea_runner
sudo -u gitearunner gitea_runner generate-config > /etc/gitea_runner/config.yaml
Obtain & Configure Gitea Runner Registration Token
- Navigate to: " > Settings > Actions > Runners"
- If you don't see this, it needs to be enabled. Navigate to: " > Settings > "Enable Repository Actions: Enabled" > Update Settings"
- Click the "Create New Runner" button on the top-right of the page and copy the registration token somewhere temporarily.
- Navigate back to the GuestVM running Zensical and run the following commands.
# Start Token Registration Process
sudo -u gitearunner env HOME=/var/lib/gitea_runner /usr/local/bin/gitea_runner register --config /etc/gitea_runner/config.yaml
# Gitea Instance URL: https://git.bunny-lab.io
# Gitea Runner Token: <Gitea-Runner-Token>
# Runner Name: zensical-docs-runner
# Move Runner Config to Correct Location & Configure Permissions
sudo mv /tmp/.runner /var/lib/gitea_runner/.runner
sudo mv /tmp/.runner /var/lib/gitea_runner/.runner
sudo chown gitearunner:gitearunner /var/lib/gitea_runner/.runner
sudo chmod 600 /var/lib/gitea_runner/.runner
Create Gitea Runner Service
Now we need to configure the Gitea runner to start automatically via a service just like the Zensical Watchdog service.
# Create Gitea Runner Service
sudo tee /etc/systemd/system/gitea-runner.service > /dev/null <<'EOF'
[Unit]
Description=Gitea Actions Runner (gitea_runner)
After=network-online.target
Wants=network-online.target
[Service]
Environment=HOME=/var/lib/gitea_runner
User=gitearunner
Group=gitearunner
WorkingDirectory=/var/lib/gitea_runner
ExecStart=/usr/local/bin/gitea_runner daemon --config /etc/gitea_runner/config.yaml
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
# Remove Container-Based Configurations to Force Runner to Run in Host Mode
sudo sed -i \
'/^[[:space:]]*labels:/,/^[[:space:]]*cache:/{
/^[[:space:]]*labels:/c\ labels:\n - "zensical-host:host"
/^[[:space:]]*cache:/!d
}' \
/etc/gitea_runner/config.yaml
# Enable and Start the Service
sudo systemctl daemon-reload
sudo systemctl enable --now gitea-runner.service
Gitea Runner Repository Workflow
Place the following file into your documentation repository at the given location and this will enable the runner to execute when changes happen to the repository data.
name: GitOps Automatic Documentation Deployment
on:
push:
branches: [ main ]
jobs:
zensical_deploy:
name: Sync Docs to https://kb.bunny-lab.io
runs-on: zensical-host
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Sync repository into /srv/zensical/docs
run: |
rsync -rlD --delete \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='assets/' \
--exclude='schema/' \
--exclude='stylesheets/' \
--exclude='schema.json' \
--chmod=D2775,F664 \
. /srv/zensical/docs/
- name: Notify via NTFY
if: always()
run: |
curl -d "https://kb.bunny-lab.io - Zensical job status: ${{ job.status }}" https://ntfy.bunny-lab.io/gitea-runners
Traefik Reverse Proxy Configuration
It is assumed that you use a Traefik reverse proxy and are configured to use dynamic configuration files. Add the file below to expose the Zensical service to the rest of the world.
http:
routers:
kb:
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: kb
rule: Host(`kb.bunny-lab.io`)
services:
kb:
loadBalancer:
servers:
- url: http://192.168.3.8:80
passHostHeader: true