Push to main. Hugo rebuilds in CI, the built site ships to the server over SCP, the container force-recreates, and the new content is live in well under a minute, in practice. No port ever opens on the server. That last part is the whole point — and once you’ve run a static site this way, the idea of exposing a box to the internet to serve HTML feels archaic.
This is the real pipeline behind this site. The configs below are the ones in the repo, not a tidied-up rewrite.
The pipeline at a glance#
flowchart TB
PUSH["git push · branch main
self-hosted git host on the LAN"] --> BUILD
subgraph DRONE["Drone CI · builds on push"]
BUILD["build step
hugomods/hugo:exts-0.164.0
hugo --environment production --minify"] --> SCP["copy step
appleboy/drone-scp
ships public/ + docker-compose.yml"] --> SSH["deploy step
appleboy/drone-ssh
docker compose up -d --force-recreate web"]
end
SSH -->|SCP + SSH · outbound to ~/hugo-site/| APPS
USER["Visitor browser"] -->|HTTPS| CF["Cloudflare edge
TLS terminate · CDN · DDoS"]
CF -.->|"Cloudflare Tunnel · outbound only"| CT
subgraph ROUTER["MikroTik L009UiGS router · armv7"]
CT["cloudflared container
--protocol http2"]
end
CT -->|"HTTP · port 80 only"| GW["gateway VM · Traefik v3"]
GW -->|Host header route| APPS["apps VM · Hugo container :8888
zero inbound ports to the internet"]
Three Drone steps build and ship the site; the serving path then runs through three separate boxes — the router, the gateway VM, and the apps VM. The interesting bits are what’s not there: no port forwarding on the router, no exposed origin IP, no TLS certificate on the box.
Where the code lives#
The source for this site lives on my git host — self-hosted, on the same LAN as the deploy target. The push that triggers the pipeline is a plain git push to that host.
A note on CI history, plainly: this pipeline used to run on Woodpecker. I moved it to Drone for the same webhook-reliability reasons I moved the mobile pipeline — Woodpecker would silently miss pushes against my git host, and Drone hasn’t. The full reasoning is in the Woodpecker-to-Drone migration write-up.
The Drone pipeline, annotated#
---
kind: pipeline
type: docker
name: default
trigger:
branch:
- main
event:
- push
steps:
- name: build
image: hugomods/hugo:exts-0.164.0
commands:
- hugo --environment production --minify
- name: copy
image: appleboy/drone-scp
depends_on: [build]
settings:
host:
from_secret: DEPLOY_HOST
# credentials (user + auth) are injected from Drone's secret store, not the repo
source:
- public/
- docker-compose.yml
target: ~/hugo-site/
rm: true
- name: deploy
image: appleboy/drone-ssh
depends_on: [copy]
settings:
host:
from_secret: DEPLOY_HOST
# credentials injected from Drone's secret store
script:
- cd ~/hugo-site
- docker compose up -d --force-recreate web
Step by step:
build. Pullhugomods/hugo:exts-0.164.0, runhugo --environment production --minify. The output is the rendered static site inpublic/, assets fingerprinted and minified. Nothing else happens in this step — it’s a pure function of the source.copy.appleboy/drone-scpshipspublic/anddocker-compose.ymlto~/hugo-site/on the deploy host. Therm: trueflag is the important one: it wipes the target directory before writing, so stale assets from a removed page don’t linger. Forget it and you’ll occasionally ship a deleted image to production.deploy.appleboy/drone-sshrunscd ~/hugo-site && docker compose up -d --force-recreate web.--force-recreateis what picks up the new content — the image is the same, but the bind-mountedpublic/has changed, and the recreate ensures the container re-reads the directory rather than holding anything in cache.
The from_secret: references resolve in Drone’s secret store. The repo contains no credentials and no host IP — those live in Drone’s UI under the repo’s secret settings.
What runs on the server#
# docker-compose.yml
services:
web:
image: hugomods/hugo:nginx
restart: always
labels:
prometheus.scrape: "false"
ports:
- "8888:80"
volumes:
- ./public:/site
hugomods/hugo:nginx is the same project’s Nginx-served variant — it serves whatever is in /site as static files. The compose binds ./public (what SCP just dropped) to /site in the container. Port 8888:80 exposes Nginx on 8888 on the host, and that port is reachable only from the LAN — not from the internet. The prometheus.scrape: "false" label opts the container out of metrics scraping, which keeps Prometheus clean of a static-site target that has nothing useful to report.
This compose file ships with the deploy. SCP carries both public/ and docker-compose.yml, so a change to the runtime config is just another commit — push, and Drone handles it.
Security, plainly#
Zero inbound ports on the LAN. Origin IP not published in DNS — the public DNS for the site points at the tunnel, not at any IP I own. TLS terminated at Cloudflare’s edge; the hop from the edge to my origin rides the tunnel’s own encrypted connection back to Cloudflare.
No “military-grade” anything. No invented SSL-Labs score. The architecture is the security claim: there’s nothing to attack from outside except Cloudflare’s edge, and the only way in is the outbound tunnel my own machine initiated. If you want the full edge story — UFW rules, the gateway’s trustedIPs setup, the rate-limit and security-header tiers — that’s in the self-hosted zero-trust homelab write-up.
Cost, plainly#
Hardware I already own. A domain per year. Cloudflare’s free tier — the tunnel and DNS cost me nothing at my traffic. The software stack (Hugo, Drone, hugomods/hugo:nginx, Traefik, cloudflared) is open source and costs zero.
I’m not going to fabricate a five-year savings table. If you don’t already have hardware, that’s the one real upfront line item; the rest is your time.
Same engine, different target#
The Drone instance that builds this site is the same one that ships mobile releases to the app stores. Different target — static files to a single VM vs. signed binaries to App Store Connect and Play Console — but the same self-hosted CI, the same webhook plumbing, the same secret store. The patterns reuse cleanly: a build step, a delivery step, a notification step. The mobile CI/CD write-up walks through the app-store side of the same engine.
When it breaks, where to look#
Real commands for the real flow:
# Drone build logs → Drone UI
# On the server:
docker compose -f ~/hugo-site/docker-compose.yml logs web
# Traefik router health (gateway VM):
curl -s http://<gateway-ip>:<traefik-api-port>/api/http/routers | jq '.[].name'
The pipeline fails in three places inside it, plus the edge layer. The build fails when Hugo doesn’t like the content — bad front matter, a broken shortcode, a missing image — and the Drone UI shows the exact line. The copy fails when SSH credentials are wrong or the deploy host is unreachable; rare, and the SCP plugin logs clearly. The deploy fails when Docker can’t pull the image or the compose file is malformed — docker compose ... logs web on the server tells you which.
The edge-layer failure mode is the one to watch for: Traefik returns 404 for the site. That’s almost always a stale or missing router after a config change. The curl against the gateway’s API lists every router Traefik currently knows about; if the site’s hostname isn’t in the list, the route file is wrong or wasn’t reloaded.
Frequently asked#
Drone vs Woodpecker for a static site?#
Both work. I moved to Drone for webhook reliability against my git host — Woodpecker would silently miss pushes, and a static-site pipeline that doesn’t run on push is a pipeline that doesn’t exist. Drone’s webhook handling has been stable. The full reasoning, including the failure modes I was hitting, is in the migration write-up.
Why SCP and not a container registry?#
Because a static site is just files. The output of a Hugo build is a directory of HTML, CSS, JS, and images — there’s no binary to version, no image layer to cache, no runtime to pin. SCP’ing public/ to the server and recreating the container is the simplest thing that could possibly work, and for a site this size it’s also the fastest. A registry is the right call when you’re shipping a real image — an app server, an API, anything with a process that has to keep running. For a directory of static files, it’s overhead with no payoff.
Do I need Traefik if I have Cloudflare Tunnel?#
You don’t need it — cloudflared can route directly to a single backend. The reason I keep Traefik in the path is that the tunnel serves more than this one site. Traefik gives me hostname routing for all of them in one config language, rate limits per route, security headers per route, and a single place to add the next service. If this site were the only thing on the tunnel, I could collapse the layers. It isn’t, so I don’t.
How long does a deploy take?#
Under a minute, push to live, as observed — not formally benchmarked. The build is fast (Hugo is fast), the SCP is a few hundred files over a LAN link, and docker compose up -d --force-recreate completes in a second or two. If a deploy ever takes more than two minutes, something is wrong and the Drone UI will show where.
Want this behind your site?#
This is the same pipeline and the same edge infrastructure I use for client work — self-hosted CI, an outbound-only tunnel, a reverse proxy I control. If you want a setup like this behind your own product instead of a managed host you can’t see inside, look at what I offer or start a conversation. For the broader picture — same engine shipping app-store releases — read the mobile CI/CD write-up.

