Every time I push a commit to this blog, a chain of automation kicks in. Within minutes, the changes are live on the internet. No manual builds, no FTP uploads, no server SSH sessions. Just git push and walk away.
Let me show you how it works.
The Stack#
This site runs on a deceptively simple stack:
- Hugo - Static site generator
- Gitea Actions - CI/CD pipeline (GitHub Actions compatible)
- Docker - Containerized deployment
- Nginx - Reverse proxy and web server
- Hetzner VPS - Production hosting
The beauty is in the automation. Every component does one thing well, and they all work together seamlessly.
The Build Process#
1. Write Content#
I write posts in Markdown, commit to git:
git add content/posts/how-this-site-is-hosted/
git commit -m "Add hosting architecture post"
git push origin mainThat’s it. Everything else happens automatically.
2. Gitea Actions Triggers#
The moment the push hits git.skui.io, Gitea Actions detects the change and starts the workflow. The workflow lives in .gitea/workflows/build.yml and handles everything from building to deployment.
Versioning Strategy:
Each build gets a version tag like 2026.01.28.3 (date + daily counter). This gives me:
- Chronological history - Easy to see what was deployed when
- Daily rollback points - Can revert to any build from today
- Clean tracking - No confusing build numbers
Here’s how versioning works:
# Pull the latest image and check what version it is
docker pull git.skui.io/steffen/hugo-blog:latest
# Find all tags for that image
LATEST_TAGS=$(docker image inspect git.skui.io/steffen/hugo-blog:latest | \
jq -r '.[0].RepoTags[]?')
# Extract today's highest counter
LATEST_TAG=$(echo "$LATEST_TAGS" | \
grep -E "2026.01.28.[0-9]+$" | \
sort -V | tail -1)
# Increment: 2026.01.28.2 → 2026.01.28.3
VERSION="2026.01.28.$((COUNTER + 1))"Why this approach?
I considered several alternatives:
- Run number - Doesn’t reset daily, gets messy over time
- Time-based (HHMM) - Hard to track and remember versions
- API queries - Platform-specific, complex, fragile
Pulling :latest and inspecting its tags is simple, fast (Docker caches), and works with any Docker registry. No custom APIs, no platform lock-in.
3. Multi-Stage Docker Build#
The Dockerfile uses a two-stage build to keep the final image small:
Stage 1: Build the site
FROM hugomods/hugo:0.154.5 AS builder
WORKDIR /src
COPY . .
# Build static site with minification
RUN hugo --minifyStage 2: Serve with Nginx
FROM nginx:alpine
# Copy built site from builder stage
COPY --from=builder /src/public /usr/share/nginx/html
EXPOSE 80The builder stage (Hugo + dependencies) is ~500 MB. The final image? Just 54 MB. We discard all the build tools and keep only the static HTML/CSS/JS and a lightweight Nginx server.
4. Push to Registry#
Both tags get pushed to the Gitea container registry:
docker push git.skui.io/steffen/hugo-blog:2026.01.28.3
docker push git.skui.io/steffen/hugo-blog:latestThe versioned tag gives me rollback capability. The latest tag makes deployment simple.
5. Deploy to Production#
Once the build succeeds, a second job SSHs into the production server and replaces the running container:
# Detect which Docker network the proxy uses
NETWORK=$(docker inspect nc-nginx-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{println $k}}{{end}}' | head -n1)
# Stop old container
docker rm -f skui.io_site || true
# Pull and run new version
docker pull git.skui.io/steffen/hugo-blog:latest
docker run -d --name skui.io_site \
--restart unless-stopped \
--network "$NETWORK" \
git.skui.io/steffen/hugo-blog:latestThe container joins the same Docker network as the Nginx reverse proxy (nc-nginx-1), which routes traffic from the internet to the site.
The Reverse Proxy Setup#
On the production server, an Nginx container handles incoming HTTPS traffic and forwards it to the Hugo site container. The configuration looks like this:
server {
listen 443 ssl http2;
server_name skui.io;
# SSL configuration handled by reverse proxy
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://skui.io_site:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}This setup gives me:
- TLS termination - SSL handled by the proxy, not the app
- Internal routing - Containers talk via Docker networks, no exposed ports
- Multiple sites - One proxy can route to many containers
For more details on the reverse proxy setup, see my Homelab documentation on edge routing.
Origin IP Protection#
The origin server IP is protected by Cloudflare. You can verify this with a simple curl:
curl -sI https://skui.io/posts/how-this-site-is-hosted/Output shows:
HTTP/2 200
server: cloudflare
cf-cache-status: DYNAMIC
cf-ray: 9c4ee4ea4c0b7127-OSLNo X-Real-IP, X-Forwarded-For, or origin server headers are exposed. Only Cloudflare is visible as the reverse proxy, hiding the actual server IP (redacted) behind their infrastructure. Requests route through Cloudflare’s edge network before reaching the origin.
This provides:
- DDoS protection - Cloudflare absorbs attacks before they reach the server
- Origin IP concealment - No direct access to the backend server
- CDN caching - Static assets served from Cloudflare’s edge (when configured)
- SSL/TLS termination - Cloudflare handles certificate management
Why This Setup?#
Fast Deployments#
From git push to live site: 29 seconds (actual production stats)
- Build: 24 seconds (Hugo compilation + Docker image build)
- Deploy: 5 seconds (Container pull and restart on production server)
The Docker image push happens during the build phase and is fast thanks to the 54 MB image size and registry caching.
Rollback in Seconds#
Something breaks? Roll back to any previous version:
docker stop skui.io_site
docker rm skui.io_site
docker run -d --name skui.io_site \
--restart unless-stopped \
--network "$NETWORK" \
git.skui.io/steffen/hugo-blog:2026.01.28.2 # Previous versionDone. Site is back to the last working state.
No Server Maintenance#
The server just runs Docker. No Hugo installation, no build dependencies, no package updates. Everything is in the container. Update Hugo? Change one line in the Dockerfile and push.
Portable#
This entire setup could move to a different provider in an hour:
- Spin up a new VPS
- Install Docker
- Pull the latest image
- Run the container
- Point DNS
No server-specific configuration. No manual setup steps. Just Docker.
The Numbers#
Image size: 54 MB
Build time: 24 seconds
Deploy time: 5 seconds
Total deployment: 29 seconds (git push to live)
Rollback window: Any version from the last 30 days
Lessons Learned#
Keep versioning simple. My first approach used complex API queries to detect the last build. It was fragile and hard to debug. The current solution (pull latest + inspect tags) is dead simple and works everywhere.
Multi-stage builds matter. My first Dockerfile bundled Hugo with Nginx. 500 MB images are slow to push/pull. Splitting build and runtime stages cut it to 54 MB.
Docker networks > exposed ports. Internal container-to-container communication via Docker networks is faster and more secure than exposing ports and routing through localhost.
Automate everything. No manual deployment steps means no forgotten steps. No SSH sessions means no “works on my machine” problems. Push code, walk away, it just works.
Future Improvements#
- Health checks - Add Docker health checks to detect broken deployments
- Blue-green deployment - Run old and new versions simultaneously, switch traffic after validation
- Optimize Cloudflare caching - Fine-tune cache rules for static assets (CSS, JS, images)
- Build caching - Cache Hugo modules to speed up repeated builds
Conclusion#
This setup has been running for months with zero downtime incidents. It’s fast, reliable, and requires almost no maintenance. The entire infrastructure is code - versioned, reproducible, and portable.
And it all starts with git push.
- Steffen

