Building My Portfolio Site - The Technical Art of Web Dev
Building My Portfolio Site
As a Technical Artist, I'm used to optimizing pipelines and solving weird engine quirks. So naturally, when I decided to build my portfolio site, I couldn't just use a template. I had to build a custom pipeline using Go, HTMX, and Docker.
It was supposed to be simple. It wasn't. Here is a breakdown of the technical hurdles and how I solved them.
1. The Container Size Problem
The Problem: My initial Docker image was a massive 600MB+. I was using debian:bookworm-slim because it was the "safe" choice for wkhtmltopdf, the tool I use to generate the PDF version of my CV.
The Solution: Optimization.
I switched to alpine:3.20 for the base image, but installing wkhtmltopdf on Alpine is notoriously painful due to dynamic library dependencies (Qt, X11).
Instead of compiling from source (which takes forever) or using the broken official packages, I used a multi-stage build to pull a pre-compiled binary from ghcr.io/surnet/alpine-wkhtmltopdf.
# Builder Stage
FROM golang:1.25-alpine AS builder
# ... build app ...
# Binary Source Stage
FROM ghcr.io/surnet/alpine-wkhtmltopdf:3.18.0-0.12.6-small AS wkhtmltopdf_source
# Final Stage
FROM alpine:3.20
# Install runtime deps (libstdc++, libx11, etc)
RUN apk add --no-cache libstdc++ libx11 ...
# Copy binary
COPY --from=wkhtmltopdf_source /bin/wkhtmltopdf /usr/bin/wkhtmltopdf
Result: The final image size dropped to 96.6 MB. A ~84% reduction.
2. The PDF Rendering Nightmare
The Problem: wkhtmltopdf uses an ancient version of WebKit (QtWebkit). It has no idea what CSS Variables (var(--color)) or CSS Grid are. My beautiful, modern CV looked completely broken when printed.
The Solution: A dedicated legacy stylesheet.
I created static/css/pdf_styles.css specifically for the renderer. It essentially dumbs down the layout:
- Replaced CSS Grid with
display: table(the old school way). - Hardcoded all colors (no variables).
- Explicitly hid the "Download" button.
Then, I updated the Go backend to inject this specific stylesheet when the /cv/pdf endpoint is hit, while the web version uses the modern one.
3. The HTMX Scroll Quirk
The Problem: HTMX simulates a Single Page Application (SPA). When you clicked a link in the header, the content swapped, but the scroll position stayed where it was (often at the bottom of the page).
The Solution: Explicit swap instructions.
I updated the hx-swap attribute in the layout to force a scroll-to-top on every transition.
<body hx-swap="innerHTML show:window:top">
I also added scroll-padding-top to the CSS, so when clicking anchor links (like in the Table of Contents), the sticky header doesn't cover the section title.
4. The Cloudflare Tunnel Saga
The Problem: Integrating the Cloudflare Tunnel directly into compose.yaml led to a "Connection Timed Out" error, even though the tunnel logs said it was connected.
The Debugging:
- Zombie Processes: I suspected a rogue
cloudflaredprocess on the host machine stealing the route. - Internal Networking: I verified connectivity inside Docker using a busybox container.
webwas reachable. - IPv6 vs IPv4: This was the culprit.
Go's default http.ListenAndServe(":"+port) binds to dual-stack IPv6/IPv4. Docker's internal networking sometimes prioritizes IPv6, while the tunnel expected IPv4.
The Fix: I forced the Go server to bind explicitly to IPv4:
// main.go
http.ListenAndServe("0.0.0.0:"+port, nil)
And I forced the Tunnel to use HTTP2 (TCP) instead of QUIC (UDP) to avoid network blocks:
command: tunnel run --protocol http2
5. Security Hardening
The Problem: A security audit revealed that generating PDFs on the fly was a massive DoS vector, and the server was missing several production-grade protections.
The Solution:
- PDF Pre-generation: Instead of spawning
wkhtmltopdfon every request, I now generate the PDF once during application startup and serve it from memory. This prevents attackers from melting the CPU with concurrent PDF requests. - In-Memory Caching: I implemented a startup cache for all markdown content. The site no longer hits the disk or runs the markdown parser on every page load—it's all served from RAM.
- Accurate IP Logging: Behind a Cloudflare Tunnel,
r.RemoteAddris useless (it just shows the tunnel's IP). I updated the logging middleware to respectCF-Connecting-IP. - Strict Routing: Enforced
GETmethods on all public routes using Go 1.22's enhanced routing to prevent unexpected behaviors with other HTTP methods.
Conclusion
The site is now fast (<100ms loads), lightweight (running on a tiny Alpine container), and fully automated via Docker Compose. It was a bit of a journey, but "problem solving" is literally the job description.
If you're stuck on wkhtmltopdf on Alpine or Cloudflare Timeouts, I hope this helps.