Let’s kick off this blog with the obligatory rite of passage for any technical blog: the “how it’s made” post. Peeling back the curtain to show how this content gets from my keyboard to your screen is practically a tradition.
The Goals
My philosophy for this project is simple: keep it simple. I have no interest in using large cloud platforms or building a complex Rube Goldberg machine just to serve static files.
This blog runs on a Hetzner VPS that costs €4.11 per month. In return I get 2 vCPUs and 4 GB of RAM, which is more than enough for a static site. It was simply the smallest server they had at the time. It also hosts a few small services I use for experimentation.
Infrastructure
The entire setup is defined in a single Git repository containing just two files:
docker-compose.yml and Caddyfile. That’s it.
The blog itself runs in a minimal Nginx container serving static files from /var/www/blog.
When I want to add a new service or tweak the configuration, I edit the files locally and run
git pull on the server.
Simple, version-controlled, and reliable.
docker-compose.yml:
services:
blog:
image: nginx:alpine
container_name: blog-server
volumes:
- /var/www/blog:/usr/share/nginx/html:ro
restart: unless-stopped
dev:
image: ghcr.io/boalbert/simple-web-server:latest
environment:
- VERSION=dev
restart: always
caddy:
image: caddy:latest
container_name: caddy
restart: always
ports:
- "80:80"
- "443:443"
volumes:
# 1. Mount Caddy configuration for routing
- ./Caddyfile:/etc/caddy/Caddyfile
# 2. Store SSL certificates
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
Caddyfile:
dev.boalbert.se {
reverse_proxy dev:8080
}
blog.boalbert.se {
reverse_proxy blog:80
}
Why Caddy?
Caddy handles three important tasks:
- Routing: It reverse-proxies requests to the correct container based on the subdomain (for
example,
blog.boalbert.seroutes to the blog container). - SSL Termination: HTTPS traffic is terminated at Caddy, so backend containers don’t need to handle encryption.
- Automatic TLS: Certificates are automatically provisioned and renewed with Let’s Encrypt, which removes a lot of manual maintenance.
Static Site Generation with Hugo
This site is built with Hugo, a static site generator written in Go. It is fast, straightforward, and perfectly fits the minimalistic design philosophy behind this setup.
Creating a new post is as simple as running:
hugo new posts/2025/11/obligatory-infrastructure-blog-post.md
With Hugo I also get the benefits of
- Markdown content with front matter
- Built-in syntax highlighting
- Fast, static builds, Generates HTML quickly, with no runtime dependencies.
Deployment Pipeline (If You Can Call It That)
There is no complex CI/CD pipeline here. I use those at work.
For this blog, deployment is handled by a single, reliable command: rsync.
When I am ready to publish, I simply sync the generated public/ directory to the server.
It is fast, reliable, and requires no additional services.
I wrapped the command in a small deploy.sh script for convenience:
# -a: archive mode (preserves permissions, timestamps, etc.)
# -v: verbose output
# -z: compress file data during transfer
# --delete: remove files on the server that do not exist locally
rsync -avz --delete public/ username@<ip-address>:/var/www/blog
Wrapping Up
That is the entire setup. Compact, inexpensive, and easy to maintain. By combining Docker, Caddy, and Hugo, I get a secure and fast blog that deploys with a single command and stays completely out of my way.