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:

  1. Routing: It reverse-proxies requests to the correct container based on the subdomain (for example, blog.boalbert.se routes to the blog container).
  2. SSL Termination: HTTPS traffic is terminated at Caddy, so backend containers don’t need to handle encryption.
  3. 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.