Hosting a static site on GCP using Hugo - cheap and automated edition
A few months ago I wrote a small article on how to host a static site on GCS. Despite it is an ideal deployment schema for a professional site, some of the GCP services I used have a non risible cost, especially load balancing tools.
In addition, a lot of people would use these resources only to experiment their functioning, or to run a personal small site, so not having a real need of the expensive performance the LB tools offer.
Every GCP account has access to a free tier where a small but effective set of services are free to use and explore.
So I thought: “Why not use these free resources to reach the same result, but avoiding worthless expenses?”.
While keeping this in mind, I suddenly realize that I’m going to repeat the same task (deploy a static site) in a different fashion: what about repeatability? Why not use an automation tool this time? This would offer the possibility to automate further changes and eventually moving all the information to a new platform.
Automate tool choosen: Terraform
Terraform
Everyone is using Terraform out here, but why (and why not other tools instead)?
First: mainly because is simple. You wrote a briefly document in HCL defining your infrastructure, and Terraform does all the dirty work.
Because HCL is declarative, so you need to define what to do and Terraform knows how, using an automatically generated (in most cases) dependency graph to ensure consistency on resource creation.
Second: Terraform has a lot of providers, think of them as “drivers” for different computing platforms. Only to name a few:
- Cloud providers: GCP, AWS, Azure, Openstack, …
- Orchestrators: Kubernetes, Nomad, …
- Virtulization solutions: VMware vSphere, DigitalOcean, Rancher, …
- …
Full list here.
In addition to “official” providers you can write (and publish) a provider of your own.
Third: it has a lot of integration options for further CI/CD automation. Very complex to expose here, so you can learn more at VCS providers dedicated page.
But why not use another tool? Maybe a conf manager? Maybe Ansible?
The answer is simple: every tool has its purpose.
Ansible is great at configuring an already existing platform.
Terraform is great at create the platform: it is all about infrastructure.
A proper way to govern a complex project is to use both of them, if you’re interested watch this short presentation by Szymon Datko & Arie Bregman at Open Infrastructure Summit 2020
GCP initial configuration
When Terraform runs, it uses existing credentials to access GCP API set. GCP IAM best practices suggest to use a separated service account in order to isolate account responsibility when operating on a project.
If you haven’t yet, create a SA for Terraform automation:
gcloud iam service-accounts create <sa_name> --description="SA for testing Terraform" --display-name="terraform-sa"
Retrieve its ID (it has email format)
gcloud iam service-accounts list
Create and obtain a new key for the SA (keep safe this file)
gcloud iam service-accounts keys create ./key.json --iam-account <sa_email>
Assign proper roles to the SA
gcloud projects add-iam-policy-binding <project_name> --member=serviceAccount:<sa_email> --role=roles/compute.networkAdmin --role=roles/compute.admin
Infrastructure
The idea is simple: use a free tier GCP Compute Engine instance to host the site. So, from the infrastructure point of view we need to create an instance, open HTTP/HTTPS ports, set up a proper DNS record.
All these three steps will be adressed by Terraform using a very short HCL script (call it main.tf) as follows:
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "3.37.0"
}
}
}
provider "google" {
credentials = file(var.gcp_credential_file_path)
project = var.gcp_project
region = var.gcp_region
zone = var.gcp_zone
}
resource "google_dns_managed_zone" "dns_zone" {
dns_name = "${var.dns_zone_domain}."
labels = {}
name = var.dns_zone_name
visibility = "public"
timeouts {}
}
resource "google_dns_record_set" "dns_record" {
managed_zone = google_dns_managed_zone.dns_zone.name
name = "${var.dns_record_name}."
rrdatas = [google_compute_instance.blog.network_interface.0.access_config.0.nat_ip]
ttl = 300
type = "A"
}
resource "google_compute_firewall" "http_access" {
name = "allow-http-acces"
network = var.network_name
allow {
protocol = "tcp"
ports = ["80"]
}
target_tags = ["blog"]
source_ranges = ["0.0.0.0/0"]
}
resource "google_compute_firewall" "https_access" {
name = "allow-https-acces"
network = var.network_name
allow {
protocol = "tcp"
ports = ["443"]
}
target_tags = ["blog"]
source_ranges = ["0.0.0.0/0"]
}
resource "google_compute_instance" "blog" {
name = var.instance_name
machine_type = var.instance_machine_type
tags = ["blog"]
boot_disk {
initialize_params {
image = var.instance_image
size = var.instance_volume_size
}
}
network_interface {
network = var.network_name
access_config {
}
}
metadata = {
ssh-keys = "${var.instance_user}:${file(var.instance_pubkey_file_path)}"
user-data = file(var.instance_cloudinit_file_path)
}
}
The code itself is almost self explanatory, but we’ll see each part in detail.
First of all, variables are disseminated in our code, we can identify variable reference by its prefix var., e.g. var.instance_image
All variables must be defined explicitly, we can collect them in a separate file, call it interface.tf
variable "gcp_project" {
type = string
description = "Target GCP project ID"
}
variable "gcp_region" {
type = string
description = "Target GCP region ID"
default = "us-central1"
}
variable "gcp_zone" {
type = string
description = "Target GCP zone ID"
default = "us-central1-a"
}
variable "gcp_credential_file_path" {
type = string
description = "Path of credential file (Service account key)"
default = "key.json"
}
variable "instance_name" {
type = string
description = "Instance name"
default = "vm01"
}
variable "instance_machine_type" {
type = string
description = "Instance machine type"
default = "f1-micro"
}
variable "instance_image" {
type = string
description = "Instance image"
default = "ubuntu-os-cloud/ubuntu-1804-lts"
}
variable "instance_volume_size" {
type = string
description = "Instance volume size"
default = "10"
}
variable "network_name" {
type = string
description = "Network used for instance connectivity"
default = "default"
}
variable "instance_user" {
type = string
description = "Instance access username"
default = "ubuntu"
}
variable "instance_pubkey_file_path" {
type = string
description = "Path of access pubkey file"
default = "~/.ssh/id_rsa.pub"
}
variable "instance_cloudinit_file_path" {
type = string
description = "Instance cloud-init script"
default = "cloud-init.yaml"
}
variable "dns_zone_name" {
type = string
description = "Name of managed zone"
default = "example-com"
}
variable "dns_zone_domain" {
type = string
description = "Domain name of managed zone"
default = "example.com"
}
variable "dns_record_name" {
type = string
description = "DNS record name"
default = "blog.example.com"
}
output "INSTANCE_PUBLIC_IP" {
value = google_compute_instance.blog.network_interface.0.access_config.0.nat_ip
}
As you can see, every variable is defined in a block containing type, description and default value. As a last entry you can spot an output, this is a variable getting valued by Terraform during its execution, the user could read it to obtain information about the newly created infrastructure details.
Let’s have a closer look at every piece of code. The part:
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "3.37.0"
}
}
}
provider "google" {
credentials = file(var.gcp_credential_file_path)
project = var.gcp_project
region = var.gcp_region
zone = var.gcp_zone
}
state which provider (and its version) should be loaded in order to created the resources, and finally the connection parameters to use to communicate with the target environment. Keep in mind gcp_credential_file_path for a follow-up.
As you can see, syntax is very simple. Now it’s time to define our instance properties, a new instance blog will be created:
resource "google_compute_instance" "blog" {
name = var.instance_name
machine_type = var.instance_machine_type
tags = ["blog"]
boot_disk {
initialize_params {
image = var.instance_image
size = var.instance_volume_size
}
}
network_interface {
network = var.network_name
access_config {
}
}
metadata = {
ssh-keys = "${var.instance_user}:${file(var.instance_pubkey_file_path)}"
user-data = file(var.instance_cloudinit_file_path)
}
}
Everything would be familiar for a people used to work with cloud instances, but keep in mind the values reported in metadata property.
Going forward we define a DNS managed zone and a related A record to resolve our instance IP (which IP address is being actually used will be clear in a moment)
dns_name = "${var.dns_zone_domain}."
labels = {}
name = var.dns_zone_name
visibility = "public"
timeouts {}
}
resource "google_dns_record_set" "dns_record" {
managed_zone = google_dns_managed_zone.dns_zone.name
name = "${var.dns_record_name}."
rrdatas = [google_compute_instance.blog.network_interface.0.access_config.0.nat_ip]
ttl = 300
type = "A"
}
The property
rrdatas = [google_compute_instance.blog.network_interface.0.access_config.0.nat_ip]
will be valued with the IP address assigned to our new instance on first NAT exposed interface (hence, the public IP related to the instance).
The following rows define two firewall rules allowing traffic on 80 and 443 TCP port:
resource "google_compute_firewall" "http_access" {
name = "allow-http-acces"
network = var.network_name
allow {
protocol = "tcp"
ports = ["80"]
}
target_tags = ["blog"]
source_ranges = ["0.0.0.0/0"]
}
resource "google_compute_firewall" "https_access" {
name = "allow-https-acces"
network = var.network_name
allow {
protocol = "tcp"
ports = ["443"]
}
target_tags = ["blog"]
source_ranges = ["0.0.0.0/0"]
}
You can note the tag field. It indicates the tags of the involved instances, if you look back at the instance declaration, you can find the exactly same tag assigned.
A piece is missing yet: how to properly value all the variables? We can define a variables file (call it terraform.tfvars) like the following:
gcp_project = "example"
gcp_region = "us-central1"
gcp_zone = "us-central1-a"
gcp_credential_file_path = "key.json"
instance_name = "blog"
instance_machine_type = "f1-micro"
instance_image = "ubuntu-os-cloud/ubuntu-2004-lts"
instance_volume_size = "10"
instance_user = "ubuntu"
instance_pubkey_file_path = "~/.ssh/id_rsa.pub"
instance_cloudinit_file_path = "cloud-init.yaml"
dns_zone_name = "example.com"
dns_zone_domain = "example.com"
dns_record_name = "blog.example.com"
NOTE: GCP instances are included in the free tier ONLY when located in the US regions (except West Virginia)
Further information on how to value variables can be found here
Do you remember gcp_credential_file_path var? It should contain the SA key which should be used to authenticate to GCP, so make sure to insert the path of the file you created when gcloud iam service-accounts keys create ran.
As mentioned, metadata property is an important part of our instance configuration:
metadata = {
ssh-keys = "${var.instance_user}:${file(var.instance_pubkey_file_path)}"
user-data = file(var.instance_cloudinit_file_path)
}
The relevant part is user-data: on instance boot, the contained information will be passed to the instance. Now, the file HCL builtin routine reads content from the path defined in var.instance_cloudinit_file_path, in our case containing a cloud-init script.
The ssh-keys field simply defines a list (of length 1 in this case) of <username>:<public_key> pairs to define cloud user passwordless SSH access to the instance.
To run our example, we need to prior install Terraform as indicated here.
Once the CLI is installed, cd into the directory we collected all previous files, and run
terraform init
It will initialize the project environment. After that, we can check the syntax of every file in the dir typing:
terraform validate
To see a preview of the changes we defined in our code, run:
terraform plan
A detailed report of new resources or changes on existing ones will be shown.
To finally apply the changes, use:
terraform apply
After requesting a confirmation, Terraform will start to operate, creating all the resources.
If you need to delete the created resources, you can use
terraform destroy
When we defined the code, we didn’t need to specify a creation order for the resources, because Terraform read the files and entailed a directed acyclic dependency graph of requested resources, and used it do define a strategy. If you want to visualize this graph you can use some simple Linux tool.
First of all, install Graphviz, then run:
terraform graph > graph.dot
dot graph.dot -Tsvg -o graph.svg
You can now open graph.svg in an image visualizer.
Software configuration
Once our infrastructure is up and running, we need to configure a bit of software inside our instance. The goal is quite simple, and I decided to not introduce another automation tool in the process, so I used a cloud-init script, as I mentioned before:
#cloud-config
packages:
- nginx
runcmd:
- rm -rf /var/www/html
- ln -s /home/ubuntu/blog.example.com /var/www/html
- chown ubuntu /home/ubuntu/blog.example.com
- add-apt-repository ppa:certbot/certbot -y
- apt-get update
- apt install python3-certbot-nginx -y
- certbot --nginx -n -d blog.example.com --email <a_valid_email> --agree-tos --redirect --hsts
- sleep 60; systemctl restart nginx
write_files:
- owner: root:root
path: /etc/cron.d/letsencrypt_renew
content: "15 3 * * * /usr/bin/certbot renew --quiet"
Also in this case, the code is clear:
- I wanted to use NGINX as web server (it has great performance on serving static content) so it installs related package
- creates a new directory in the cloud user dir to host the site files, and creates a link to substitute the document root
- installs
certbotutility to obtain a new SSL certificate in order to set up HTTPS (if you’re thinking to skip this part I want you to know we are in 2020) - waits a minute and restarts NGINX
- enables a cronjob to automatically renew the certificate
Obviously this scripts works only on Ubuntu instances, but you can easily change it to make it work on a different distro.
Wait a minute or two, navigate to the specified domain, if you can see NGINX working (but using an empty dir) everything is up.
Now, last step: we need to move the content files (learn how to create the content in my previous post), rsync will do the job:
rsync -azP <hugo_dir>/public/ <cloud_user>@<instance_IP>:/home/ubuntu/blog.example.com
Remember to check about FW rules on SSH port if it fails (and optionally close it afterwards).
Again, we’re in 2020, so please version your Terraform code in addition to your site content.
Well, at this point you can navigate to your site e check the content.
Using this configuration you should afford only the cost of a GCP managed DNS zone, but the cost is low, we are talking about 0,13 € per day. If you want to avoid those costs, you can use your domain provider DNS instead of use the GCP service, but you will lose the automation Terraform offers to set up DNS records (or better, you can modify the code to gain the same automation level when using a different DNS).