Motivation
I wanted a personal site that reflects my work philosophy: fast, secure, reproducible, and automated. No WordPress, no heavy CMS. A static site generated with Hugo, served behind a hardened reverse proxy, deployed with Docker Compose, and ready to scale.
Architecture
┌──────────────────────────────────────────────────┐
│ Internet │
└──────────────────────┬───────────────────────────┘
│
┌────────▼────────┐
│ Cloudflare DNS │
└────────┬────────┘
│
┌────────▼────────┐
│ Traefik v3 │
│ (Reverse Proxy)│
│ :80 → :443 │
└────────┬────────┘
│
┌────────▼────────┐
│ Hugo Server │
│ (PaperMod) │
│ :1313 │
└─────────────────┘
Components
| Component | Technology | Purpose |
|---|---|---|
| Static generator | Hugo + PaperMod | Generates HTML/CSS/JS site |
| Reverse proxy | Traefik v3 | TLS termination, security headers, rate limiting |
| Certificates | Let’s Encrypt + DNS Challenge | Automatic TLS via Cloudflare |
| Containers | Docker Compose | Service orchestration |
| DNS | Cloudflare | DNS management and caching |
Hugo + PaperMod
I chose Hugo for its extreme speed and PaperMod for its clean, minimalist design.
Site Features
- Bilingual (ES/EN): Full content in Spanish and English with language switcher
- Auto dark/light theme: Respects system preference with manual toggle
- Built-in search: JSON index + Fuse.js for client-side search without a server
- SEO optimized: OpenGraph, meta tags, sitemap.xml, robots.txt
- No external JavaScript: Zero trackers, zero invasive analytics
Content Structure
content/
├── about.md / about.en.md # About me
├── search.md / search.en.md # Search
├── experience/
│ ├── index.md / index.en.md # Professional experience
├── projects/
│ ├── index.md / index.en.md # Projects
└── posts/
├── _index.md / _index.en.md # Blog index
├── homelab-hashicorp-stack.md # Posts...
└── ...
Multilingual Configuration
Hugo allows declarative language definition in hugo.yaml:
defaultContentLanguage: es
languages:
es:
languageName: "🇪🇸 Español"
weight: 1
en:
languageName: "🇬🇧 English"
weight: 2
Each content file has its .en.md variant for the English version. Hugo handles automatic routing (/en/about/, /en/posts/, etc.).
Security with Traefik
Security is not optional. I configured Traefik with a middleware chain that enforces:
Security Headers (OWASP)
secure-headers:
headers:
stsSeconds: 31536000 # HSTS 1 year
stsIncludeSubdomains: true
stsPreload: true
frameDeny: true # Anti-clickjacking
contentTypeNosniff: true # Anti MIME-sniffing
browserXssFilter: true # XSS protection
referrerPolicy: "strict-origin-when-cross-origin"
contentSecurityPolicy: "default-src 'self'; ..."
Rate Limiting
rate-limit:
rateLimit:
average: 100
burst: 200
period: 1s
Hardened TLS
tls:
options:
default:
minVersion: "VersionTLS12"
cipherSuites:
- "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
- "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
- "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305"
- "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305"
sniStrict: true
Middleware Chain
All middlewares are chained into a single security-chain:
security-chain:
chain:
middlewares:
- secure-headers
- rate-limit
- compress-response
- www-redirect
Deployment with Docker Compose
Deployment is a simple docker compose up -d:
services:
traefik:
image: "traefik:v3.7.7"
security_opt:
- no-new-privileges:true
# ... Traefik configuration
web:
image: hugomods/hugo:exts-non-root-0.154.5
command:
- server
- "--bind=0.0.0.0"
- "--appendPort=false"
- "--baseURL=https://www.gbt55.es"
- "--disableLiveReload"
labels:
- "traefik.http.routers.web.rule=Host(`www.gbt55.es`) || Host(`gbt55.es`)"
- "traefik.http.routers.web.middlewares=security-chain@file"
Design Decisions
- Hugo in server mode instead of static build: allows content hot-reload without container rebuild
no-new-privileges: prevents privilege escalation inside the containernon-rootimage: Hugo process runs as non-root user- Dedicated
proxynetwork: network isolation between services - Let’s Encrypt with DNS Challenge: no need to expose port 80 for validation
Automatic TLS Certificates
Certificates are managed automatically with Let’s Encrypt using Cloudflare’s DNS challenge:
- "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
This enables automatic renewal without downtime and without opening additional ports.
VPS Hardening
The VPS hosting the portfolio includes a hardening script (vps-harden.sh) that configures:
- Firewall (UFW) with restrictive rules
- SSH hardening (no root login, key-only auth)
- Fail2ban for brute force protection
- Automatic security updates
Result
The result is a site that is:
- ⚡ Ultra-fast: Hugo generates pages in <1ms, served with gzip compression
- 🔒 Secure: OWASP headers, TLS 1.2+, rate limiting, restrictive CSP
- 🐳 Reproducible: A single
docker compose up -ddeploys everything - 🌍 Bilingual: Full content in ES/EN
- 📱 Responsive: PaperMod is fully responsive out-of-the-box
- 🎨 Clean: No ads, no trackers, no cookies
Full Stack
Hugo (SSG) + PaperMod (theme)
│
Docker Compose (orchestration)
│
Traefik v3 (reverse proxy + TLS + security)
│
Let's Encrypt (automatic certificates)
│
Cloudflare (DNS + edge caching)
│
Hardened VPS (Linux)
The source code for this portfolio is available on my GitHub.