I decided to build my personal site with Hugo, a static site generator. The goal was something simple: two blogs, a photo gallery behind authentication, and a clean design that stays out of the way of the images.

Why Hugo

Hugo is fast. Builds complete in milliseconds, even with hundreds of pages. It’s a single binary — no Node.js, no Ruby, no Python runtime needed. And it has built-in support for taxonomies (tags, categories) which saved me from bolting on something custom.

The Dual-Image Approach

The interesting architectural decision was the image model. Blog posts include smaller, compressed versions of photos that are publicly accessible. Each one links to a full-size version on an image detail page that requires authentication:

[![Alt text](/images/blog/small-version.jpg)](/gallery/album-name/photo-id/)

The small images live in the Hugo static/ directory and deploy with the site. The full-size originals live in a separate S3 bucket, served through CloudFront with Lambda@Edge doing JWT validation.

Rather than creating a Markdown file for every photo (500+ would get unwieldy), I used Hugo’s data files. Each photo gets a small YAML file:

id: photo-001
filename: IMG_2024_001.jpg
caption: "Sunset over Capitol Lake"
albums:
  - olympia-sunsets
exif:
  camera: "Canon EOS R5"
  aperture: "f/8"

A shell script generates thin content stubs from these YAML files, and Hugo templates do the rest — album pages, image detail pages, prev/next navigation.

What’s Next

The authentication layer uses AWS Cognito with Google sign-in, enforced at the CDN edge. That’s a whole post on its own.

Things I Got Wrong the First Time

Hugo doesn’t clean public/ between builds. Hugo does incremental builds — if content and templates haven’t changed, it skips regenerating those pages. A page built months ago with a different baseURL will sit in public/ indefinitely. When aws s3 sync runs, it sees the same file size and skips uploading. The result: gallery pages pointed to the old CloudFront domain long after I’d switched to a custom domain. The fix is hugo --cleanDestinationDir --minify, which deletes public/ before each build. It adds a fraction of a second to the build time and eliminates the whole class of problem.

uglyURLs = true only applies to leaf pages. Hugo’s uglyURLs setting turns about/index.html into about.html for regular content pages. But section pages (_index.md) stay as section/index.html regardless. This means the gallery landing page is at /gallery/index.html, not /gallery.html. I had a default redirect in auth-callback.js pointing to /gallery.html that hit 404 after every login.

S3 + CloudFront doesn’t serve directory indexes for subdirectory paths. CloudFront’s DefaultRootObject = index.html only applies to the root URL. A request for /gallery/ looks for the S3 key gallery/ — which doesn’t exist — and returns 403. The correct URL is /gallery/index.html. Hugo generates explicit index.html links in nav and album cards, so normal in-site navigation works fine. But any link you hand-type or bookmark needs the explicit filename.

The gallery S3 bucket path must include the gallery-images/ prefix. CloudFront has a cache behavior for /gallery-images/* that routes to the gallery S3 bucket. When CloudFront forwards the request to S3, it sends the full path — including /gallery-images/. The upload script originally synced to s3://BUCKET/thumbnails/, but CloudFront was looking for gallery-images/thumbnails/. The fix was a one-line change to the upload script, followed by re-uploading everything.