Running an Internal Blog to Document Your Homelab
This is a follow-up to Clean HTTPS URLs for Your Home Server Services, where I covered setting up Pi-hole, Nginx Proxy Manager, and a wildcard Let’s Encrypt certificate to get clean HTTPS URLs for all local services. If you have not read that one yet, it is a good starting point.
Keeping track of a homelab gets complicated fast. Which port does that service run on? Why did I configure Pi-hole that way? What was the fix for that weird networking issue six months ago? Writing it down somewhere persistent saves a lot of pain later. This post covers how I set up a private Jekyll blog, hosted on my home server, that auto-deploys from a GitHub repo whenever I push a new post.
The Setup
The stack is intentionally simple:
- Jekyll for static site generation
- GitHub to store the source and posts
- GitHub Actions to build and deploy on every push
- nginx running in Docker on CasaOS to serve the built site
- Nginx Proxy Manager for HTTPS with a wildcard Let’s Encrypt cert
- Tailscale to let GitHub Actions reach the home server securely
The result is a site at https://docs.yourdomain.com reachable from any device on the local network, with a real trusted certificate.
Why Jekyll
Jekyll builds a static site from Markdown files. No database, no PHP, no runtime to maintain. The output is just HTML and CSS served by nginx. It is fast, simple, and the source files are plain text that live comfortably in git.
For internal documentation, the content model maps well too. Posts for dated notes and writeups, and a custom _services collection for living documentation about each running service.
Repo Structure
internal-blog/
├── _config.yml
├── _posts/
│ └── 2026-05-28-homelab-setup.md
├── _services/
│ ├── sonarr.md
│ ├── radarr.md
│ ├── pihole.md
│ └── ...
├── _layouts/
│ ├── default.html
│ ├── post.html
│ ├── page.html
│ └── service.html
├── assets/css/style.css
├── index.html
├── services.html
└── .github/workflows/deploy.yml
The _services collection is the most useful part. Each service gets its own Markdown file with frontmatter for the URL, port, and status:
---
title: Sonarr
layout: service
url_local: https://sonarr.yourdomain.com
port: 8989
status: running
description: TV series management and download automation
---
TV show manager. Monitors RSS feeds and downloads episodes automatically.
## Notes
- Connected to Prowlarr for indexer management
- Downloads go to /mnt/Storage1/Media/TV
For Jekyll to treat _services as a collection, declare it in _config.yml:
collections:
services:
output: true
Without this, the files in _services are ignored. Once declared, the homepage and services page loop over the collection to render a table of all services with their URLs and status. It becomes a quick reference for anything running on the server.
The Deployment Problem
GitHub Actions runners are on the public internet. The home server is on a private LAN. They cannot reach each other directly.
The fix is Tailscale. The home server already runs Tailscale, and the GitHub Actions workflow connects the runner to the same Tailscale network using an auth key before running rsync. From that point the runner can reach the server at its Tailscale IP as if it were on the same local network.
The GitHub Actions Workflow
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "4.0"
bundler-cache: true
- name: Build Jekyll site
run: bundle exec jekyll build
env:
JEKYLL_ENV: production
- name: Connect to Tailscale
uses: tailscale/github-action@v3
with:
authkey: $
version: latest
- name: Deploy via rsync
env:
SSH_KEY_B64: $
DEPLOY_HOST: $
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY_B64" | base64 -d > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
rsync -avzr --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
_site/ \
user@${DEPLOY_HOST}:/home/user/docs-site/
A few things worth calling out:
The SSH key is stored base64-encoded. GitHub Actions can mangle multiline secrets when they are retrieved in a shell script. Encoding the private key as a single base64 line and decoding it in the workflow avoids the issue entirely. This tripped me up for a while before I found it.
Tailscale connects before rsync runs. The runner joins your Tailscale network as an ephemeral node, runs the deploy, then disappears. No persistent node, no cleanup needed.
rsync with --delete keeps the served directory in sync with exactly what Jekyll built. Files removed from the repo are removed from the server on the next deploy.
Secrets Required
| Secret | Value |
|---|---|
TAILSCALE_AUTHKEY |
Ephemeral reusable auth key from Tailscale admin |
DEPLOY_HOST |
Tailscale IP of the home server |
DEPLOY_SSH_KEY |
Base64-encoded Ed25519 private key |
Generate the deploy key on the server and add the public key to authorized_keys:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_deploy_key -N ""
cat ~/.ssh/github_deploy_key.pub >> ~/.ssh/authorized_keys
Then encode the private key for the GitHub secret:
base64 -w 0 ~/.ssh/github_deploy_key
Paste that single line as the DEPLOY_SSH_KEY secret value.
Serving with nginx
The built site lands at /home/user/docs-site/ on the server. A lightweight nginx container serves it:
docker run -d \
--name docs \
--restart unless-stopped \
-p 8899:80 \
-v /home/user/docs-site:/usr/share/nginx/html:ro \
nginx:alpine
Nginx Proxy Manager handles the HTTPS layer, routing docs.yourdomain.com to port 8899 with the wildcard *.yourdomain.com certificate.
One gotcha: bind the container to 0.0.0.0:8899 not 127.0.0.1:8899. NPM runs in its own Docker container and cannot reach the host loopback. Use the server LAN IP as the forward host in NPM instead of 127.0.0.1.
Services Table
All services are documented in the _services collection and rendered as a table:
| Service | URL | Port |
|---|---|---|
| Sonarr | https://sonarr.yourdomain.com | 8989 |
| Radarr | https://radarr.yourdomain.com | 7878 |
| Bazarr | https://bazarr.yourdomain.com | 6767 |
| Prowlarr | https://prowlarr.yourdomain.com | 9696 |
| Pi-hole | https://pihole.yourdomain.com | 8800 |
| Portainer | https://portainer.yourdomain.com | 9000 |
| qBittorrent | https://qt.yourdomain.com | 8181 |
| Nginx Proxy Manager | https://npm.yourdomain.com | 81 |
| Synology NAS | https://nas.yourdomain.com | 5000 |
| Router | https://router.yourdomain.com | 80 |
| Internal Docs | https://docs.yourdomain.com | 8899 |
Writing Workflow
Once everything is set up, adding a post is just:
vim _posts/2026-05-31-some-topic.md
git add . && git commit -m "add post" && git push
The site updates in about 30 seconds. No SSH, no server management, no manual steps.
For quick notes, the GitHub web editor works too. Edit a file directly on github.com and the deploy triggers automatically.
This post was written with the help of AI (Claude by Anthropic).