Self-Hosted Hugo: Building a Deployment Pipeline Without CI/CD

Most Hugo tutorials end with “deploy to Netlify” or “push to GitHub Pages.” If you’re running your own infrastructure, neither of those is satisfying. Here’s the pipeline I settled on for deploying this site to a self-hosted Caddy VM.

Architecture

Windows dev machine          Caddy VM (DMZ)
────────────────             ──────────────
hugo --minify ──── SCP ────▶ /opt/caddy/site/
                             Caddy serves static files
                             Cloudflare proxies + caches

No containers. No CI runners. No GitHub. The pipeline is a batch script, a Caddy file_server block, and an SSH key.

The Hugo Setup

The site uses BeautifulHugo as a git submodule. One critical gotcha: use git clone --recurse-submodules or the themes directory will be empty and hugo will error with a confusing “template not found” message.

git clone --recurse-submodules http://forgejo.internal/repo/oidrissi.com-source.git

Build output goes to public/ which is in .gitignore — you never commit build artifacts, only source.

The Deploy Script

The core of the pipeline is about 20 lines:

REM publish.bat — must be on 'main' branch to run

hugo --minify
scp -i "~/.ssh/id_ed25519-homelab" -r public/* [email protected]:/opt/caddy/site/

The full script adds a branch guard (refuses to deploy unless you’re on main), a git pull to sync first, and error checking after each step. Running it from any other branch aborts immediately — useful when you have a dev branch with drafts you haven’t published yet.

Branch Strategy

Two branches keep things clean:

BranchPurpose
devIn-progress content, draft: true posts
mainReady to publish — what gets deployed

hugo server -D on dev renders drafts. On main, hugo --minify only builds non-draft content. This means I can start writing a post, commit it as a draft to dev, and it won’t appear on the live site until I flip draft: false and merge.

Caddy Configuration

On the server, the Caddyfile entry for the static sites is minimal:

oidrissi.com, www.oidrissi.com {
    root * /srv
    file_server
    encode zstd gzip
    import static_security
    import site_log oidrissi.com

    @assets path_regexp \.(css|js|woff2?|ttf|jpg|jpeg|png|webp|svg|ico)$
    header @assets Cache-Control "public, max-age=31536000, immutable"
}

file_server serves the Hugo output. encode zstd gzip handles compression. The rest is security headers and caching — Caddy handles TLS automatically via Let’s Encrypt.

The site directory on the server is just /opt/caddy/site/. SCP overwrites it on each deploy. No blue-green, no rollback — it’s a personal blog, not a bank.

Cloudflare Cache

Because Cloudflare sits in front of the server, the first deploy after a content change needs to warm the cache. I don’t bother purging manually; the Cloudflare cache rule is set to a 1-month edge TTL, so new content appears immediately (the cache is cold for that URL) and old content stays cached until it naturally expires or I push a purge.

Hugo’s asset fingerprinting (resources.Fingerprint) means CSS and JS filenames change on every build when their content changes, so those never serve stale versions regardless of cache TTL.

What I’d Change

The main limitation is that deploy is manual — I have to remember to run publish.bat. The natural next step is Forgejo Actions: a workflow that triggers on push to main, runs hugo --minify, and SCPs the output automatically.

The other thing missing is atomic deploys. SCP copies files one by one, so there’s a brief window where old and new files coexist. For a personal blog this is acceptable; for anything with more traffic I’d SCP to a staging directory and mv it into place.

Both are on the roadmap. For now, the manual pipeline is fast enough (build + upload takes about 15 seconds) that I haven’t felt the friction.

Categories: Homelab Development 

See also