Leaving this here as a reminder of how this site works since I will inevitably forget. In a nutshell, it is:

  • Built using Hugo
  • Checked into GitHub
  • Run using the seemingly fashionable serverless approach via Google Cloud Run

The goal is to have the site automatically rebuilt/deployed every time a commit is pushed to a GitHub repo’s main branch.

The reason for the more-complex-than-necessary setup is that I wanted to learn a little more about a few things I’d never used, mainly Docker. In reality this is overkill and the site should just be served from GCS/S3, but this works for now.

Hugo

Things have changed since the GeoCities ‘type your raw HTML into this textarea’ days. Static-site generators seem to be a popular replacement - there are lots of options, but I chose Hugo since it is written in Go (which I’d like to use more) and is easier to spell than Jekyll.

My setup is nothing fancy - I followed the Quick Start , added new pages with:

$ hugo new posts/some-post.md

And served locally with:

$ hugo server -D

The command hugo alone builds the site and is used by the later steps.

GitHub

Storing the site on GitHub serves two purposes:

  • Version control, etc., etc.
  • Common platform for various serving options (Cloud Run, Netlify, GitHub Pages, etc.)

I plan to experiment with a few options just for fun, so having it on GitHub simplifies that.

Docker

My Docker understanding is fledgling at best, but containerizing will allow it to run on Cloud Run (or another system in the future).

While trying to write my own Dockerfile, I came across the klakegg/hugo image, which demonstrated I was way out of my depth and also solved everything. This allows for building the site and then serving it with nginx:

FROM klakegg/hugo:0.82.0-onbuild AS hugo

FROM nginx
COPY --from=hugo /target /usr/share/nginx/html

With this a container can be built and run:

$ docker build . -t tb-com
$ docker run --rm -p 80:80 tb-com:latest

To jump ahead a bit, Cloud Run sends requests to the port $PORT on the container, defaulting to 8080 ( docs ). I assumed that a later step would require specifying the arguments to pass to a docker run command, at which point we could replace the above command with:

$ docker run --rm -p $PORT:80 tb-com:latest

However that seems to be wrong. Based on the examples , the $PORT needs to be used in application code, which in this case is nginx.

I made a number of mistakes setting this up, all due to my lack of nginx knowledge. The trickiest one was overriding the port - passing the value through wasn’t complicated (see run.sh below), but I wasn’t overriding the correct files. I ended up with this config:

daemon off;

events {}

http {
  include          /etc/nginx/mime.types;
  server {
      listen       ${PORT} default_server;
      listen       [::]:${PORT} default_server;
      server_name  localhost;
      location / {
          root   /usr/share/nginx/html;
      }
  }
}

And used the following to replace ${PORT} and then override /etc/nginx/nginx.conf:

#!/bin/bash
set -eux

# Cloud Run will provide `${PORT}`, but allow this to be run in other contexts.
export PORT=${PORT:-8080}

# Swap the `$PORT` into the nginx config.
envsubst '$PORT' < /build/nginx.template > /etc/nginx/nginx.conf

exec nginx

I put these files in a new build directory alongside the other Hugo directories.

Now the Dockerfile can be extended to copy the build directory into the container and run the script:

# Hugo Docker image. `onbuild` builds the site and places it in `/target`.
FROM klakegg/hugo:0.82.0-onbuild AS hugo

# Serve the generated files with `nginx`.
FROM nginx
COPY --from=hugo /target /usr/share/nginx/html

# Copy the /build/ directory into the container. This brings in the nginx
# template and the script to do the replacement.
COPY build /build/

# Serve the files.
CMD ["/build/run.sh"]
EXPOSE 8080

At this point, everything should be good to go:

  • The container builds the site with hugo
  • Files are copied into /usr/share/nginx/html
  • nginx starts/listens on ${PORT}, which will be substituted at runtime

To test locally:

$ docker build . --rm -t tb-com:latest
$ docker run . --rm -t tb-com:latest

# Run with a different port environment variable.
$ docker run --rm -p 1234:9876 --env PORT=9876 tb-com:latest

Cloud Run

In case a disclaimer is necessary: I work for Google but not in Cloud - part of choosing Cloud Run was just to learn more about it.

With the site checked into GitHub, the next step is automating the deployment. I followed the continuous deployment docs , and after a few hiccups (accidentally created two build triggers, created a build trigger with the incorrect Build type, etc.), all worked.

To test prior to submitting, I used Cloud Build to locally build the container and then manually deployed it. This required a cloudbuild.yaml:

steps:
  - name: 'gcr.io/cloud-builders/docker'
    args: [
      'build',
      '--no-cache',
      '--pull',
      '--file', 'Dockerfile',
      '--tag', '$IMAGE',
      '.']

images:
  - '$IMAGE'

Which was built with:

$ gcloud builds submit \
    --config cloudbuild.yaml \
    --substitutions IMAGE=gcr.io/${PROJECT_ID}/${SERVICE}:${TAG}

And deployed with:

$ gcloud beta run deploy \
    ${SERVICE}  \
    --platform managed \
    --region us-central1     \
    --image ${IMAGE} \
    --allow-unauthenticated

After confirming everything worked ‘manually’, I made some local tweaks and then pushed everything to GitHub, which also worked.

Takeaways

This project had one of the highest documentation_read / code_generated ratios of anything I’ve worked on. Most of that is due to my lack of knowledge of the space (I knew/know nothing about Hugo, Docker, serverless, etc.), but also analysis paralysis - because there are so many options it is never clear whether what you’re doing is ‘correct’ or laughably wrong. At some point I just stopped caring in order to get something done, and overall it turned out fine.