Hosting static site with Hugo, Nginx, and Cloud Run

gmarik 5 min

TLDR

Cloud Run is a great platform to host your static site. The article describes the process of setting up one with focus on best practices and simplicity.

Hosting gmarik.info

My blogging setup had changed couple of times now:

The last platform was fast and worked well except few things:

So Cloud Run was an obvious candidate once it was announced:

Requirements

Along with previous requirements :

  1. https support
  2. git push style deploys
  3. custom domain
  4. support for various static site compilers
  5. cheap
  6. fast

I wanted to:

  1. apply observability principles to the static site: monitor 404s
  2. have flexibility with redirects and maintaining legacy urls
  3. have a way to to filter out annoying scanners and what not

So with new the stack, Cloud Build takes care of:

Cloud Run takes care of:

And I went with nginx to take care of:

Managing 404s is extremely important to ensure readers find what they’re looking for.

Prerequisites

  1. setup a GCP project or use an existing one.
  2. latest gcloud (or beta with gcloud components install beta)
  3. for gcloud, configure your environment with the project and account:
 export CLOUDSDK_CORE_ACCOUNT=youraccount@example.com
 export CLOUDSDK_CORE_PROJECT=your-project

Plan

  1. create Docker image and push it to Cloud Registry
  2. create Cloud Run Service
  3. continuos deploys with Cloud Build Triggers
  4. hooking up Hugo

Here’s the initial project’s structure:

$ tree 
├── cloudbuild
│   ├── cloudbuild.yaml
│   └── cmd.sh
├── nginx
│   ├── docker-entrypoint.sh
│   ├── etc
│   │   └── nginx
│   │       └── conf.d
│   │           └── site01.conf
│   └── nginx.Dockerfile
└── site01
    └── public
        └── index.html

Hugo will be added later.

Create Docker Image

To test our image let’s build it locally first:

docker build \
   --build-arg SITE=site01 \
   -t gcr.io/${CLOUDSDK_CORE_PROJECT}/site01:latest \
   -f nginx/nginx.Dockerfile .

and run:

docker run -it -p 8080:8080 gcr.io/${CLOUDSDK_CORE_PROJECT}/site01:latest

if everything is ok you should be able to see:

$ curl localhost:8080
<html>
  <body>
    hello cloud run world
  </body>
</html>

Deploy image to Cloud Run

Before Cloud Run can deploy our image it has to be in a registry, so let’s push it to gcr:

$ docker push gcr.io/${CLOUDSDK_CORE_PROJECT}/site01:latest
The push refers to repository [gcr.io/.../site01]
f1b5933fe4b5: Layer already exists 
latest: digest: sha256:300bcf2fba9da6a120693a9edcc53453d135b80e21932df7f3ebdcc45f732fec size: 1567

Conveniently gcloud beta run deploy creates the Cloud Run service if it doen’t exists, so let’s deploy right away:

$ gcloud beta run deploy --platform=managed --region=us-central1 --allow-unauthenticated --image=gcr.io/${CLOUDSDK_CORE_PROJECT}/site01:latest site01
Deploying container to Cloud Run service [site01] in project [yourrpoject] region [us-central1]
✓ Deploying new service... Done.
✓ Creating Revision...
✓ Routing traffic...
✓ Setting IAM Policy...
Done.                                                                                                                                                                                                          
Service [site01] revision [site01-e6fbee39-d08f-4906-a016-ecd921635bdb] has been deployed and is serving traffic at https://site01-wy3lc5tzpa-uc.a.run.app

and test:

curl  https://site01-wy3lc5tzpa-uc.a.run.app
<html>
  <body>
    hello cloud run world
  </body>
</html>

Great success!

Continuos deploys with Cloud Build

  1. Connect the source repo by adding a trigger
  2. Configure automated build
  3. Configure permissions for Cloud Build to deploy Cloud Run (see Cloud Run tab)

Once everything is configured properly the site gets build on every git push. Nice!

Hooking up Hugo

This means adding an intermediary step to produce the site content

  1. echo site01/public >> .gitignore because we want public/ built with Hugo
  2. rm site01 to prepare for generated content
  3. initialize site with hugo new site site01
  4. download a theme, ie Niello (cd site01/themes/ && curl -L https://github.com/guangmean/Niello/archive/1.0.tar.gz|tar -xz)
  5. configure theme with echo 'theme = "Niello-1.0"' >> site01/config.toml
  6. test locally with hugo -s ./site01 server
  7. git push origin to have it built and deployed

Once the Cloud Build completes the static site is compiled and deployed.

The full source code is available at gmarik/starterkit-static_site-cloud-run-nginx-hugo

Appendix: Nginx’s Docker image

Running nginx on Cloud Run is the same as running nginx in Docker except the dynamic PORT contract and it took me some time to figure it out

although some say it’s not so dynamic:

You can sort of safely hard code port 8080 in your nginx.conf as it’s very unlikely to change in the foreseeable future on Cloud Run — https://stackoverflow.com/a/57171522/928095

I didn’t want to hardcode so followed the hard way:

Back to the code.

The Dockerfile looks simple but notice the docker-entrypoint.sh bit:

FROM nginx:1.16-alpine as nginx
ARG SITE=site01

# Config
COPY nginx/etc/nginx /etc/nginx
# Sources
RUN mkdir -p /var/www/${SITE}/public
COPY ${SITE}/public /var/www/${SITE}/public
# Initialization
COPY nginx/docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]

CMD ["nginx", "-g", "daemon off;"]

The docker-entrypoint.sh takes care of the $PORT contract, by replacing ${NGINX_PORT} placeholder by a value from $PORT environment variable:

#!/usr/bin/env sh
set -eu
## conform to service contract https://cloud.google.com/run/docs/reference/container-contract
NGINX_PORT=${PORT:-8080}
# and set the NGINX_PORT to $PORT
sed -i "s/\${NGINX_PORT}/${NGINX_PORT}/g" /etc/nginx/conf.d/*.conf

echo "nginx: testing config"
nginx -t
echo "nginx: starting on $NGINX_PORT"

exec "$@"

in conf.d/*.conf, that may look like this:

# simplified gmarik.info.conf
server {
   server_name www.gmarik.info;
   listen ${NGINX_PORT};
   listen [::]:${NGINX_PORT};
   # do not use :PORT in redirect
   port_in_redirect off;
   root /var/www/gmarik.info/public;
   index index.html;
   error_page 404       /404.html;
}

after it runs as Docker ENTRYPOINT and then starts up the nginx command as specified in the Dockerfile.

Appendix: cleaning up Cloud Run revisions

Currently there’s no UI to mass-delete unused revisions but it’s easily script-able:

export CLOUDSDK_CORE_ACCOUNT=youremail@example.com
export CLOUDSDK_CORE_PROJECT=yourproject
export SITE_NAME=site01

REVISIONS=$(gcloud beta run revisions list --platform=managed|awk -v site=$SITE_NAME '$3==site {print $2}'|tail -n+5)
for r in $REVISIONS; do gcloud -q beta run revisions delete --platform=managed --region=us-central1 $r; done

NOTE: it keeps 4 revisions by skipping with tail -n+5

Example:

$ REVISIONS=$(gcloud beta run revisions list --platform=managed|awk -v site=$SITE_NAME '$3==site {print $2}'|tail -n+5)
$ echo $REVISIONS
site01-00077 site01-00076 site01-00075 site01-00074 ...
$ for r in $REVISIONS; do gcloud -q beta run revisions delete --platform=managed --region=us-central1 $r; done
Deleted revision [site01-00077].
Deleted revision [site01-00076].
...

References

Related Posts
Read More
Datadog APM: Traces + Metrics
12 factor configuration with Go's `flag` package
Comments
read or add one↓