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).