Read it live at debene.dev.
Personal blog running on Hugo with the PaperMod theme. Self-hosted on a Kubernetes homelab, built and deployed by a GitHub Actions workflow.
✨ Fully automated: Push to main → GitHub Actions builds a container image, rolls out the deployment, and purges the CDN cache.
Internet → Cloudflare Tunnel → K8s (nginx pod serving the built image)
↑
kubectl rollout restart
↑
GitHub Actions (self-hosted runner)
↑
GitHub Push to main
- Generator: Hugo
std-0.157.0(via thehugomods/hugo:std-0.157.0build image) - Theme: PaperMod (git submodule)
- Web Server: nginx:alpine serving the static site baked into the container image
- Container build:
nerdctl buildon a self-hosted homelab runner (containerd,k8s.ionamespace) - Hosting: Kubernetes homelab (
blognamespace) - CDN/Tunnel: Cloudflare Tunnel (
cloudflareddeployment in-cluster) - Domain: debene.dev
The pipeline is a GitHub Actions workflow (.github/workflows/deploy.yml) that runs on a
self-hosted homelab runner. On every push to main (or a manual workflow_dispatch) it builds a
container image, restarts the Kubernetes deployment, and purges the Cloudflare cache.
┌─────────────┐
│ Git Push │ Developer pushes to main branch
└──────┬──────┘
│
▼
┌──────────────────────────────────────────────┐
│ GitHub Actions — job: build-and-deploy │
│ runs-on: [self-hosted, homelab] │
│ │
│ 1. Checkout (submodules: true, full history) │
│ 2. nerdctl build (Hugo + nginx image) │
│ tags: blog-debene-dev:latest │
│ blog-debene-dev:<git-sha> │
│ into the containerd k8s.io namespace │
│ 3. Write kubeconfig from KUBE_CONFIG_B64 │
│ 4. kubectl -n blog rollout restart │
│ deployment/blog │
│ kubectl -n blog rollout status (120s) │
│ 5. Purge Cloudflare cache (purge_everything) │
└──────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ K8s namespace: blog │
│ - Deployment/blog (nginx:alpine) │
│ serves the freshly built image │
│ - Service/blog (port 80) │
│ - Deployment/cloudflared (tunnel) │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Cloudflare Tunnel (cloudflared pod) │
│ - Secure tunnel to Cloudflare edge │
│ - No open firewall ports │
└──────────────┬───────────────────────┘
│
▼
┌───────────────┐
│ Internet │
│ debene.dev │
└───────────────┘
Dockerfile is a two-stage build:
- Builder —
hugomods/hugo:std-0.157.0copies the repo in and runshugo --minify. - Runtime —
nginx:alpinecopies the generatedpublic/directory to/usr/share/nginx/htmland addsnginx.conf.
The whole site is baked into the image, so Deployment/blog serves static files straight from the
container — there is no shared storage volume. Because the deployment uses
imagePullPolicy: IfNotPresent with the :latest tag, the workflow ships new content with a
kubectl rollout restart rather than by changing the image reference.
The workflow reads three repository secrets:
KUBE_CONFIG_B64— base64-encoded kubeconfig for the cluster. Set it with:base64 < ~/.kube/config | gh secret set KUBE_CONFIG_B64 -R <owner>/<repo>CF_ZONE_ID— Cloudflare zone ID fordebene.dev.CF_API_TOKEN— Cloudflare API token with cache-purge permission.
# Install Hugo (macOS)
brew install hugo
# Clone with submodules (theme)
git clone --recurse-submodules https://github.com/felipedbene/debene-dev.git
cd debene-dev
# Run dev server with drafts
hugo server -D
# Build for production (test locally)
hugo --minifyCreate new post:
hugo new content/posts/my-post-slug/index.mdDirectory structure (Page Bundles):
content/posts/my-post-slug/
├── index.md # Post content
└── images/ # Post images
├── cover.png
└── screenshot.png
Front matter template:
---
title: "Post Title"
date: 2026-05-10T18:00:00-05:00
draft: false
tags: ["tag1", "tag2"]
categories: ["category"]
cover:
image: "images/cover.png"
alt: "Alt text"
---
Post content here...Just push to main:
git add .
git commit -m "New post: My Awesome Article"
git push origin main
# GitHub Actions builds the image and rolls out the deployment.
# Watch the run: gh run watch -R felipedbene/debene-devThe workflow handles everything:
- ✅ Build the Hugo + nginx container image (
nerdctl build) - ✅ Write the kubeconfig from the
KUBE_CONFIG_B64secret - ✅
kubectl rollout restart+rollout statustheblogdeployment - ✅ Purge the Cloudflare cache
- ✅ Live site update
Deploy runs (GitHub Actions):
# Watch the latest deploy
gh run watch -R felipedbene/debene-dev
# List recent runs
gh run list -R felipedbene/debene-dev --workflow deploy.ymlBlog access logs:
# Nginx access logs
kubectl logs -n blog -l app=blog --tail=100 | grep -v healthzDeployment status:
# Check all blog resources
kubectl get all -n blog
# Check rollout status
kubectl -n blog rollout status deployment/blogDeploy workflow failing:
# Inspect the last run's logs
gh run view -R felipedbene/debene-dev --log-failed
# Common cause: KUBE_CONFIG_B64 secret missing/expired — the workflow
# errors out early in the "Write kubeconfig from secret" step.Hugo build failed:
# Reproduce locally — the CI build runs the same command
hugo --minifySite not updating:
# Check the deployment picked up the new image
kubectl -n blog rollout restart deployment/blog
kubectl -n blog rollout status deployment/blog
# Confirm the Cloudflare cache was purged (re-run the workflow if needed)Written from a basement in Chicago where an IBM POWER8 server runs Gentoo, AIX runs in KVM, and a
Kubernetes cluster ties it all together. The blog you're reading is built into an nginx container by
a GitHub Actions workflow on a self-hosted homelab runner, rolled out with kubectl, and delivered
via Cloudflare Tunnel — because why not? 🚀
The site's code and configuration are released under the MIT License. Blog post prose and images remain © Felipe De Bene.