↓ Skip to main content

How This Site Is Built

·1111 words·6 mins· loading · loading ·
A static site with a blog, a photo blog and an about page, built in one long day. Here’s the whole recipe, simplified, including the parts that didn’t work the first time.

The stack
#

  • Hugo (extended edition) generates the site.
  • Blowfish is the theme, added as a git submodule and never edited directly. Everything custom lives in the site’s own layouts/, assets/ and config/.
  • PhotoSwipe shows photos full-screen.
  • Firebase (Firestore with anonymous sign-in) stores view and like counts.
  • Google Analytics, loaded only after the visitor agrees.

1. Project skeleton
#

hugo new site example.com && cd example.com
git init
git submodule add -b main https://github.com/nunocoracao/blowfish.git themes/blowfish
# Blowfish wants its config split into files:
mkdir -p config/_default && cp themes/blowfish/config/_default/*.toml config/_default/
rm hugo.toml

Three sections, each post a page bundle (a folder with index.md and its images):

content/
├── blog/      text posts
├── photos/    photo posts, shown as cards
└── about/     about page, with a downloadable CV

The menu is three entries in menus.en.toml, and mainSections = ["blog", "photos"] puts both on the homepage.

2. A photo gallery#

Blowfish has a gallery, but I wanted justified rows (every photo in a row has the same height) and a proper full-screen viewer. So the site has its own photoswipe shortcode. A photo post just needs a gallery/ folder and one line:

{{< photoswipe >}}

The shortcode lists the images in the folder and makes two copies of each:

{{ range sort (.Page.Resources.Match "gallery/*") "Name" }}
  {{ $full  := .Process "fit 2560x2560 webp q85" }}
  {{ $thumb := .Process "resize x480 webp q80" }}
  <a href="{{ $full.RelPermalink }}"
     data-pswp-width="{{ $full.Width }}" data-pswp-height="{{ $full.Height }}">
    <img src="{{ $thumb.RelPermalink }}" class="nozoom" loading="lazy" alt="">
  </a>
{{ end }}

The justified rows are pure CSS: each item’s flex-grow is its aspect ratio, so a whole row scales together.

PhotoSwipe itself is vendored into assets/ (no CDN), and its script and styles load only on pages that use the shortcode.

Blowfish’s own click-to-zoom attaches to every image without a nozoom class. Without it, one click opened two viewers on top of each other.

3. Captions from the photo itself
#

Hugo can read a photo’s metadata with .Meta, so captions come from what my photo editor (Capture One) already wrote:

  • Title and description: IPTC ObjectName and Caption-Abstract.
  • Place: a keyword like Place: Lisbon.
  • Camera line: model, lens, focal length, aperture, shutter, ISO, date.
Lisbon · X100V · 23mm · f/5.6 · 1/250s · ISO 160 · 12 May 2026

EXIF numbers like aperture come back as fractions (14/5); Hugo’s float turns them into 2.8. Any caption can still be overridden in front matter.

4. Keeping GPS out of the published site
#

My photos carry GPS coordinates, and a static site happily publishes them unless you stop it. Four layers make sure nothing identifying leaks:

  1. Every photo is re-encoded. Hugo’s processed images carry no metadata at all.

  2. Originals aren’t published. By default Hugo copies every file in a post’s folder. One setting stops that:

    [[cascade]]
      [cascade.build]
        publishResources = false
  3. The social preview image and the RSS feed used the originals too. Two small template overrides make them use re-encoded copies.

  4. A build script strips whatever is left (the theme still publishes a few originals), and fails the build if anything identifying remains:

    hugo --gc --minify --cleanDestinationDir
    exiftool -r -overwrite_original -all= --ICC_Profile:all \
      -tagsFromFile @ -Orientation public/

The script keeps the colour profile and rotation, and caches its results by file hash, so unchanged photos are never processed twice. Hugo already caches its own image processing by content.

exiftool -all= -tagsFromFile @ -ICC_Profile looks like it keeps the colour profile, but silently deletes it. Exclude it from the wipe instead: --ICC_Profile:all.

5. Views and likes
#

Blowfish supports view and like counters out of the box: set the Firebase config in params.toml and turn on showViews / showLikes. Visitors are signed in anonymously and the counters live in Firestore.

The Firebase config is public by design, so the security rules are the only protection. Mine allow only the counters, changed by exactly one per write:

match /views/{id} {
  allow read: if true;
  allow create: if request.auth != null && request.resource.data.views == 1;
  allow update: if request.auth != null
    && request.resource.data.keys().hasOnly(['views'])
    && request.resource.data.views == resource.data.views + 1;
}

6. Likes on every photo
#

Each photo has its own heart, on the thumbnail and in the full-screen view. A few design decisions:

  • One document per gallery, not per photo, holding a count for each photo. Opening a gallery costs one database read, however many photos it has.
  • Rules can’t see which photo changed, so each write also names the photo it changes, and the rule checks that exactly that count moved by ±1.
  • A photo post’s like count is the sum of its photo likes. Each photo like also moves the post’s own counter in the same atomic batch, so the theme shows the right total everywhere with no extra code. Photo posts don’t get a separate “like the post” button.
const batch = writeBatch(db);
batch.set(photosDoc, { likes: { [photo]: increment(1) }, last: photo }, { merge: true });
batch.set(postDoc, { likes: increment(1) }, { merge: true });
await batch.commit();

7. Analytics, after consent#

Google Analytics is added by setting its ID in hugo.toml, but it doesn’t load until the visitor clicks Accept in a small banner:

  • Nothing is requested from Google before that, and no cookies are set.
  • The choice is remembered; Cookie settings in the footer reopens the banner, and declining later deletes the cookies.
  • Browsers sending Global Privacy Control count as Decline.
  • In development the banner works, but Accept only logs to the console, so local browsing never reaches the real statistics.

8. Polish
#

  • Reading width. Article text is 18px and 80 characters wide, set with two CSS variables in assets/css/custom.css.
  • A pixel-art favicon and logo (an emerald). Pixel art only stays crisp at whole multiples of its grid, so the logo is drawn natively at 36px rather than shrunk from 48.
  • An about page built from my CV, with the PDF downloadable.

Lessons
#

  • false in front matter often doesn’t override a site-wide true in Blowfish: Hugo’s default treats false as “not set”. Turn features on per section with cascade instead of off per page.
  • Keep theme overrides small and documented. Every copied template is something to re-check after a theme update.
  • Check privacy in the output, not the templates. Scanning the built site with exiftool found leaks that reading the code would have missed.

The repository has a README.md for me and an AGENTS.md for coding agents, so the next round of changes starts from what’s written down, not from memory.