Behind the Scenes: Hugo

Reading time: 4 minutes

As a 1 person shop with no marketing budget, I prioritize maximizing my productivity and minimizing time-consuming tasks. One of my secrets is using a static site generator to manage content on lonelydns.com.

LonelyDNS uses a static site generator called Hugo. Hugo takes the LonelyDNS website content–stored in flat files as Markdown–and builds all the HTML and style sheets required for the website.

Benefits

Using Hugo has proven to be a huge timesaver for me. Like Wordpress, I can build the theme files once that ensures all future documentation and blog posts follow the same format and branding. This allows me to focus on writing content.

However, unlike WordPress, I do not have to maintain any extra infrastructure or constantly patch a WordPress installation. Since this is a static website, I can host it on Amazon Web Services using CloudFront and S3, on Cloudflare Pages, or stand up a simple web server running only NGINX or Caddy. It may be simple and boring but this allows me to focus my time on building new features and improving the LonelyDNS.

Custom Theme Features

There are hundreds of free and paid Hugo theme options available. I did not find a prebuilt theme that met all of my needs but I was able to quickly build one from scratch with 3 main features:

1. Automatically Generated Pricing Table

The pricing page details are populated from a single JSON file. The LonelyDNS web application also uses a copy of this JSON file to populate plan levels and perform resource checks to ensure users are within their plans’ limitations. For my specific Hugo site, here is an example data file stored in data/plans.json:

[{
    "type": "monthly",
    "domain_limit": 3,
    "domain_record_limit": 20,
    "name": "Starter",
    "cost": "3.99",
    "product_id": "price_XXXXXXXXXXXXXXXXXXX",
    "sms_soft_limit": 0,
    "sms_hard_limit": 0,
    "description": "Just the basics for your personal domains",
    "features": [
      "Up to 3 domains",
      "Up to 20 records per domain",
      "5 minute intervals",
      "Email & Discord alerts"
    ],
    "features_coming_soon": [
      "Alert Integrations for Slack, & Telegram"
    ]
  },
  {
    "type": "monthly",
    "domain_limit": 10,
    "domain_record_limit": 200,
    "name": "Pro",
    "cost": "15.99",
    "product_id": "price_XXXXXXXXXXXXXXXXXXX",
    "sms_soft_limit": 100,
    "sms_hard_limit": 100,
    "description": "All the basics for starting a new business.",
    "features": [
      "Up to 10 domains",
      "Up to 100 records per domain",
      "5 minute intervals",
      "Email & Discord alerts"
    ],
    "features_coming_soon": [
      "100 SMS Credits/month",
      "Alert Integrations for Mattermost, PagerDuty, Slack, Teams, Telegram, & VictorOps",
      "Globally distributed lookups"
    ]
  }]

When new plan features are rolled out, changes to this single file controls the information displayed on the pricing page. The data file technique uses named values within the JSON file so they can be referred to directly within the Hugo template. My pricing page’s template loops through the plans data file and fills in the values as needed when Hugo builds the website. A simplified version of this template logic looks like:

{{ range .Site.Data.plans }}
  <span class="text-plan-name">{{ .name }}</span>
  <span class="text-cost">{{ .cost }}</span>
  <div class="features">
  {{ range .features }}
    <span class="text-feature">{{ . }}</span>
  {{ end }}
  </div>
{{ end }}

2. Multilingual Support

One of my long term goals is to make LonelyDNS a globally accessible tool. That requires the marketing website and web application to support internationalization (i18n) and localization (l10n). The web application is built with Elixir and Phoenix Framework so gettext does the heavy lifting.

For the marketing website, the Hugo theme was designed from the beginning to support content in multiple languages. Core parts of the template use i18n to translate strings and variables. However each content page requires a separate page per language. I use a standard Hugo multilingual technique: the default language (English) uses the content’s primary filename (e.g. behind-the-scenes-hugo.md) but each translated page adds the language code to the filename (e.g. behind-the-scenes-hugo.de.md for the German version).

3 .Publish with GitHub Actions

For each content page, I set a publication date within the page’s front matter (e.g. page metadata). When Hugo builds the site, future dates are ignored and those pages are not generated. I use this feature in combination with a GitHub Action that automatically builds the website and pushes content changes daily. This allows me to plan future blog articles in advance and enjoy vacation while they are automatically posted.

Below is the GitHub Action that performs this daily build, pushes the content to S3, and invalidates the CloudFront Cache so the new content appears.

name: Build and Deploy

on:
  push:
  schedule:
    - cron:  '0 6,18 * * *'

jobs:
  build:
    name: Build and Deploy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '16'
      - name: Cache dependencies
        id: cache
        uses: actions/cache@v2
        with:
          path: ./node_modules
          key: modules-${{ hashFiles('package-lock.json') }}
      - name: Install dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm i
      - name: Install Hugo
        run: |
          HUGO_DOWNLOAD=hugo_extended_${HUGO_VERSION}_Linux-64bit.tar.gz
          wget https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${HUGO_DOWNLOAD}
          tar xvzf ${HUGO_DOWNLOAD} hugo
          mv hugo $HOME/hugo
        env:
          HUGO_VERSION: 0.101.0
      - name: Hugo Build
        run: $HOME/hugo --minify --debug
      - name: Deploy to S3
        if: github.ref == 'refs/heads/main'
        run: $HOME/hugo -v deploy --target s3
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      - name: Invalidate CloudFront
        uses: chetan/invalidate-cloudfront-action@v2
        env:
          DISTRIBUTION: ${{ secrets.DISTRIBUTION }}
          PATHS: "/*"
          AWS_REGION: "us-east-1"
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Notice the AWS secrets–the Access Key, Secrey Key, and CloudFront Distribution ID are secrets stored within the GitHub repository’s as GitHub Actions secrets. The S3 bucket and region however is hardcoded in the Hugo configuration file’s (config.toml) deployment section:

[deployment]
    [[deployment.targets]]
      name = "s3"
      URL = "s3://mysuperawesomewebsite.com?region=us-east-1"

/fin

So that’s how I use Hugo to make managing the content on LonelyDNS a little less stressful. I’m looking forward to posting more behind the scenes articles covering the technology and design decisions I’ve made while building my first service from scratch.

Credits: Cover photo by Garett Mizunaka on Unsplash