Pilab Blog Pipeline Architecture
🧭 Overview#
This project implements a fully automated CI/CD pipeline for a personal blog using:
- Obsidian (Windows)
- Git + GitHub
- Woodpecker CI (self-hosted on Raspberry Pi 5)
- Hugo (Extended, ARM64)
- Nginx (Docker)
- Cloudflare Tunnel
- Cloudflare Edge SSL
Everything is self-hosted on pilab (Raspberry Pi 5) and exposed securely via Cloudflare Tunnel.
🏗 High-Level Architecture Flow#
Obsidian (Windows)
│
│ Auto Commit + Push
▼
GitHub Repository
│
│ Webhook (push event)
▼
Woodpecker CI (Docker on Pi)
│
│ Clone Repo
▼
Hugo Build (ARM64 container)
│
│ Generate static site
▼
/srv/blog/releases/TIMESTAMP/
│
│ ln -sfn (symlink swap)
▼
/srv/blog/current (Active Symlink)
│
▼
Nginx Container (blog-web)
│
▼
Cloudflare Tunnel (cloudflared)
│
▼
Cloudflare Edge (SSL)
│
▼
🌍 [https://blog.pilab.space](https://blog.pilab.space/)
🔄 Detailed Execution Flow#
1️⃣ Writing Phase#
Blog posts written in Obsidian, markdown files saved under:
hugo-site/content/posts/
Obsidian Git plugin performs:
pull → commit → push
2️⃣ GitHub Phase#
- GitHub receives push
- Webhook triggers:
https://woodpecker.pilab.space/hook
3️⃣ CI Phase (Woodpecker)#
Woodpecker detects .woodpecker.yml, clones repo at commit SHA, and executes the pipeline.
Pipeline Config#
steps:
build:
image: ghcr.io/gohugoio/hugo:latest
commands:
- cd hugo-site
- hugo --minify
deploy:
image: alpine:latest
volumes:
- /srv/blog:/srv/blog
commands:
- export DEPLOY_TS=$(date +%Y%m%d-%H%M%S)
- export RELEASE_DIR=/srv/blog/releases/$DEPLOY_TS
- mkdir -p $RELEASE_DIR
- cp -r hugo-site/public/* $RELEASE_DIR/
- |
if [ ! -f "$RELEASE_DIR/index.html" ]; then
echo "ERROR: index.html not found. Aborting."
rm -rf $RELEASE_DIR
exit 1
fi
- ln -sfn $RELEASE_DIR /srv/blog/current
- ls -dt /srv/blog/releases/* | tail -n +6 | xargs rm -rf
4️⃣ Deployment Phase (Capistrano-Style Release)#
New release directory created:
/srv/blog/releases/YYYYMMDD-HHMMSS/Hugo output copied into it
index.htmlvalidated — if missing, release is deleted and pipeline abortsln -sfnatomically swaps/srv/blog/currentto new releaseReleases older than last 5 are automatically cleaned up
Rollback:
ln -sfn /srv/blog/releases/YYYYMMDD-HHMMSS /srv/blog/current
5️⃣ Serving Phase#
Nginx serves static files from /srv/blog/current on port 80 internally.
No public ports exposed.
6️⃣ Ingress Phase#
Cloudflare Tunnel forwards blog.pilab.space to the internal Nginx container.
Cloudflare handles SSL termination.
🖥 Infrastructure Layout (Pi)#
Raspberry Pi 5 (pilab)
│
├── woodpecker-server
├── woodpecker-agent
├── blog-web (nginx)
├── cloudflared
└── /srv/blog/
├── current -> releases/YYYYMMDD-HHMMSS/
└── releases/
├── 20260226-120000/
├── 20260225-184500/
└── ...
🔐 Security Model#
No direct IPv4/IPv6 exposure
No open ports 80/443
Outbound-only Cloudflare Tunnel
HTTPS enforced at Cloudflare Edge
Internal traffic remains private
⚙ Obsidian Git Configuration#
Auto pull interval: 5 minutes
Auto commit interval: 10 minutes
Auto push interval: 10 minutes
Pull on startup: Enabled
Split timers: Enabled
🧠 Design Principles#
GitHub = Source of Truth
CI/CD = Automated, reproducible builds
Infrastructure = Containerized
Deployment = Timestamped releases with atomic symlink swap
Ingress = Zero-trust (Cloudflare Tunnel)
No raw IP exposure
🚀 What This Achieves#
Fully automated blog publishing
Self-hosted CI on ARM64
Zero-downtime deploys with instant rollback
Secure global access via Cloudflare
No reliance on VPS hosting
📌 Definition#
- A GitOps-based, ARM-native, zero-trust, Capistrano-style static release system.
Status: Stable & Production-Ready
Last verified: 2026-02-26