Static site generators are great until you have to deploy them. Hugo builds fast, but the workflow of building locally, copying files to a server, and hoping nothing broke gets old after two posts. This is how I automated the entire pipeline so that a git push to the blog repo triggers a build, deploys the output, and updates the live site without touching the server.
The Setup#
The blog runs on Hugo with the Blowfish theme. The webserver is a Linode VPS running nginx. Three repositories are involved:
- blog-source — Hugo source (markdown, templates, config). Private repo on GitHub.
- webroot-repo — Mirrors
/var/wwwon the webserver. Contains the built site files. When this repo gets a push, the server pulls. - nginx-configs — Nginx configuration files, managed separately.
The initial plan was simpler: install Blowfish as a git submodule inside the webroot repo and build everything in one place. That didn’t work. Blowfish relies on Hugo’s extended SCSS pipeline, and getting it to render correctly as a nested submodule inside a larger repo turned into a rathole of broken asset paths and theme resolution failures. The cleaner solution was a dedicated blog-source repo where Hugo owns the entire project structure, with CI handling the build and pushing only the compiled output to the webroot.
The goal: push a markdown file to blog-source, and have the live site update automatically.
Key Takeaways#
- A three-repo setup with GitHub Actions, a deploy key, and a Python webhook listener gets you from
git pushto live site in under 60 seconds — no SSH, no manual copying, nothing on the server - Deploy keys scoped to a single repository are the right credential for cross-repo CI/CD — not personal access tokens, not the default
GITHUB_TOKEN - Cloudflare silently breaks GitHub webhook IP allowlisting; you need
set_real_ip_fromdirectives for all Cloudflare ranges or your webhooks will 403 indefinitely git pull --ff-onlyin the webhook listener makes deploys fail loudly if the server’s checkout has diverged — no silent merge commits, no mystery state
Step 1: Install Hugo and Initialize the Repo#
Hugo’s install is straightforward. On Windows, the extended edition is needed for SCSS processing that Blowfish requires:
winget install Hugo.Hugo.ExtendedThe blog already existed as a local Hugo project with no version control. First step was creating a .gitignore to keep build artifacts out of the repo:
public/
resources/
.hugo_build.lock
*~
*.swppublic/ is where Hugo writes its output. That directory gets built in CI, not committed to the source repo.
Step 2: GitHub Actions Workflow#
The workflow file lives at .github/workflows/deploy.yml in the blog repo. It does four things:
- Checks out the blog source (including the Blowfish theme as a git submodule)
- Installs Hugo
- Builds with
hugo --minify - Clones the webroot repo, replaces the blog directory with the fresh build, and pushes if anything changed
name: Build and Deploy Hugo Blog
on:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: deploy-blog
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout blog source
uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 0
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: 'latest'
extended: true
- name: Build site
run: hugo --minify
- name: Deploy to webroot repo
env:
DEPLOY_KEY: ${{ secrets.WEBROOT_DEPLOY_KEY }}
run: |
trap 'rm -f ~/.ssh/deploy_key' EXIT
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key"
git clone --depth 1 --branch main \
[email protected]:your-username/your-webroot-repo.git \
/tmp/webroot
rm -rf /tmp/webroot/example.com/blog/public
cp -r public /tmp/webroot/example.com/blog/public
cd /tmp/webroot
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add example.com/blog/
if git diff --cached --quiet; then
echo "No changes to deploy."
else
git commit -m "Deploy blog: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
git push origin main
fiA few things worth noting:
concurrencyprevents two deploys from racing each other. If you push twice quickly, the first run gets cancelled.permissions: contents: readlimits the workflow’s GitHub token to read-only. The cross-repo push uses a separate deploy key, not the default token.trapcleans up the deploy key from the runner’s filesystem even if the step fails.- The
git diff --cached --quietcheck avoids empty commits when Hugo’s output hasn’t changed (common with config-only edits).
Step 3: Deploy Keys for Cross-Repo Push#
GitHub Actions can’t push to a different repository with its default GITHUB_TOKEN. The solution is an SSH deploy key:
ssh-keygen -t ed25519 -C "blog-deploy" -f ~/.ssh/blog_deploy -N ""The public key gets added to the webroot repo as a deploy key with write access. The private key gets stored as a repository secret (WEBROOT_DEPLOY_KEY) on the blog-source repo. The workflow uses this key to clone and push to the webroot.
Deploy keys are scoped to a single repository and can be read-only or read-write. They don’t expire like personal access tokens, and they don’t carry your full account permissions. For a CI pipeline pushing to one specific repo, they’re the right tool.
Step 4: The Webhook Listener#
When GitHub Actions pushes to the webroot repo, the webserver needs to know about it. A GitHub webhook sends a POST request to the server on every push. A small Python script listens for it:
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
REPO_PATH = os.environ.get("REPO_PATH", "/var/www")
DEPLOY_BRANCH = os.environ.get("DEPLOY_BRANCH", "main")
# ... signature validation, event filtering ...
result = subprocess.run(
["git", "pull", "--ff-only"],
cwd=REPO_PATH,
capture_output=True,
text=True,
timeout=30,
)The listener validates the HMAC-SHA256 signature against a shared secret, checks that the push was to the main branch, and runs git pull --ff-only. It runs as a systemd service on port 9000, with configuration in /etc/github-webhook.env.
The --ff-only flag is deliberate. If the server’s checkout has diverged from the remote (someone edited files directly on the server), the pull fails loudly instead of creating a merge commit. You want deploys to be predictable.
Step 5: Nginx Configuration#
Nginx proxies the webhook endpoint to the Python listener. The configuration includes an IP allowlist restricted to GitHub’s webhook source ranges, and rate limiting:
location /webhook {
allow 192.30.252.0/22;
allow 185.199.108.0/22;
allow 140.82.112.0/20;
allow 143.55.64.0/20;
deny all;
limit_req zone=webhook burst=3 nodelay;
proxy_pass http://127.0.0.1:9000/webhook;
proxy_set_header X-Hub-Signature-256 $http_x_hub_signature_256;
proxy_set_header Content-Type $content_type;
}The blog itself is served from the deployed public/ directory:
location /blog/ {
alias /var/www/example.com/blog/public/;
index index.html;
try_files $uri $uri/ $uri.html =404;
}The Problems I Hit#
No deployment pipeline works on the first try. Here’s what went wrong and how I fixed it.
Cloudflare Blocking Webhooks#
The site sits behind Cloudflare. Every webhook delivery from GitHub came back 403 Forbidden — not from nginx, but from Cloudflare itself. The Server: cloudflare header in the response gave it away.
Even after adding a Cloudflare WAF exception for the /webhook path, there was a second problem: the nginx IP allowlist was checking the source IP, but behind Cloudflare, the source IP is always Cloudflare’s proxy, not GitHub’s. The fix was adding set_real_ip_from directives for all of Cloudflare’s IP ranges and setting real_ip_header CF-Connecting-IP so nginx evaluates the original client IP:
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... all Cloudflare ranges ...
real_ip_header CF-Connecting-IP;Git Safe Directory#
The webhook listener runs as www-data, but some of the new files in /var/www were owned by root. Git’s safe directory check blocked the pull:
fatal: detected dubious ownership in repository at '/var/www'Fix: chown -R www-data:www-data /var/www and git config --global --add safe.directory /var/www for the www-data user.
SSH Host Key Verification#
After fixing permissions, the pull failed with Host key verification failed. The www-data user had no SSH configuration. Since www-data is a nologin account, I needed to generate a deploy key and tell git where to find it.
I was using Claude Code to assist with the pipeline setup, and its first suggestion was to store the SSH keys in /var/www — the web root. I caught that and vetoed it. Putting private key material inside the directory tree that nginx serves to the internet is exactly the kind of mistake that ends up in a breach postmortem. I put them in /home/www-data/.ssh/ instead and configured git to use them explicitly:
git -C /var/www config core.sshCommand \
"ssh -i /home/www-data/.ssh/id_ed25519 -o UserKnownHostsFile=/home/www-data/.ssh/known_hosts"The UserKnownHostsFile path was necessary because www-data’s home directory in /etc/passwd is /var/www, not /home/www-data, so SSH couldn’t find the known_hosts file without being told explicitly.
Stale Files in the Wrong Directory#
The final gotcha: the blog appeared to deploy but content changes weren’t visible. The nginx alias pointed to /var/www/example.com/blog/ but the CI/CD pipeline deployed files to /var/www/example.com/blog/public/. An older copy of the site existed at the non-public/ path from a previous manual deploy, and nginx was serving those stale files instead.
This one was subtle because the site looked fine — it was just frozen in time. The fix was pointing the nginx alias to the correct blog/public/ path.
The Final Pipeline#
git push (blog source)
-> GitHub Actions builds Hugo
-> Pushes built files to webroot repo
-> GitHub webhook fires
-> nginx proxies to Python listener
-> git pull updates /var/www
-> nginx serves the new contentTotal time from push to live site: under 60 seconds. No SSH. No manual copying. No build tools on the server.
The entire thing is three repos, one workflow file, one Python script, and an nginx config. Every piece is version-controlled, every secret is in GitHub’s encrypted storage or the server’s environment file, and the only thing with write access to production is a scoped deploy key that can only push to one repository.
It’s not the most sophisticated setup in the world - a smarter man may have used ansible to deploy or just re-evaluated the Blowfish installation method, but I saw an opportunity for over-engineering and I took it. I regret nothing because it works, it’s auditable, and it cost nothing beyond the Linode VPS that was already running.
