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:

it’s difficult to tune redirects and requires coding

AppEngine’s app.yaml is too restrictive and was designed for other other workflow

Too much unnecessary AppEngine’s cruft.

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

#CloudRun looks perfect: a container + runtime with per second billing. https://t.co/JA0YuH7xsk — gmarik (@gmarik) April 10, 2019

Requirements

Along with previous requirements :

https support git push style deploys custom domain support for various static site compilers cheap fast

I wanted to:

apply observability principles to the static site: monitor 404s have flexibility with redirects and maintaining legacy urls 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:

apply observability principles to the static site: monitor 404s with logging

https support(it’s non-optional)

support(it’s non-optional) custom domain

fast: warmup time for the container is almost unnoticeable

cheap

And I went with nginx to take care of:

have flexibility with redirects and ability to maintain legacy urls

have a way to to filter out annoying web-scanners

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

Prerequisites

setup a GCP project or use an existing one. latest gcloud (or beta with gcloud components install beta ) for gcloud , configure your environment with the project and account:

Plan

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

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

echo site01/public >> .gitignore because we want public/ built with Hugo rm site01 to prepare for generated content initialize site with hugo new site site01 download a theme, ie Niello (cd site01/themes/ && curl -L https://github.com/guangmean/Niello/archive/1.0.tar.gz|tar -xz) configure theme with echo 'theme = "Niello-1.0"' >> site01/config.toml test locally with hugo -s ./site01 server 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 " [email protected] "

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 [email protected] 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