Using Azure, Terraform and GitHub Actions to host an (almost) free static site

22 June 2021

Let’s start this post by saying that everything below is unnecessary. The outcome from this is exactly what GitHub Pages gives you for (completely) free. Hosting in Azure comes with a very small cost (pricing explained below), but the point of this is to learn about Azure, Terraform and GitHub Actions in the process of hosting a small, static website whilst keeping the costs very low.

Services

So what services will we be using, and how much will they cost us?

You will first need to sign up for accounts at Azure, GitHub and Terraform Cloud.

Content

We will first need a new repository on GitHub in order to push our code to. Use the instructions provided on GitHub to create or link the repository to a local folder on your computer. Within the new repository, create a src folder and then an index.html file within the folder. We will put in some placeholder content for now:

<html>
    <body>
        <h1>Hello, World</h1>
    </body>
</html>

Commit and Push that to the remote repository.

Infrastructure

Next, we will are going to create some Terraform files which will describe the resources we want to be created on Azure to host our site. Start by creating a new top level folder in your repository called .cloud.

Setup

Let’s create a main.tf file in that folder with the below contents:

terraform {
  backend "remote" {
    hostname     = "app.terraform.io"
    organization = "MY-ORG"

    workspaces {
      name = "static-site"
    }
  }

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 2.26"
    }
  }

  required_version = ">= 0.14.9"
}

provider "azurerm" {
  features {}
  subscription_id = var.azure_subscription_id
  client_id       = var.azure_client_id
  client_secret   = var.azure_client_secret
  tenant_id       = var.azure_tenant_id
}

resource "azurerm_resource_group" "static-site" {
  name     = "static-site"
  location = "uksouth"
}

Make sure you change the MY-ORG to match the organisation you entered when signing up to Terraform Cloud.

The above sets up some important information for us, including the Terraform backend and the connection to Azure (although the credentials for that will be dealt with later). It will also create our first resource in Azure: the Resource Group within which all our other resources (CDN, DNS, Storage) will be contained.

Storage

The next resource we will want to create is the Storage Account which will hold our static files to be read from the CDN. Create the file .cloud/storage.tf with the contents:

resource "azurerm_storage_account" "static-site" {
  name                      = "staticsitestorage"
  resource_group_name       = azurerm_resource_group.static-site.name
  location                  = azurerm_resource_group.static-site.location
  account_tier              = "Standard"
  account_replication_type  = "LRS"
  enable_https_traffic_only = true
  static_website {
    index_document     = "index.html"
    error_404_document = "index.html"
  }

  blob_properties {
    cors_rule {
      allowed_headers    = ["*"]
      allowed_methods    = ["GET", "HEAD"]
      allowed_origins    = ["*"]
      exposed_headers    = ["*"]
      max_age_in_seconds = 3600
    }
  }
}

CDN

Now lets add the resources to create a CDN which will be backed by the Storage Account above. This will be in a new file .cloud/cdn.tf.

resource "azurerm_cdn_profile" "static-site" {
  name                = "static-site-cdn"
  resource_group_name = azurerm_resource_group.static-site.name
  location            = "westeurope"
  sku                 = "Standard_Microsoft"
}

This adds the CDN “profile” into Azure which is just a container. We will now need to add the CDN “endpoint” which connects an external domain with an origin (our Storage Account):

resource "azurerm_cdn_endpoint" "static-site" {
  name                = "static-site-cdnep"
  profile_name        = azurerm_cdn_profile.static-site.name
  resource_group_name = azurerm_resource_group.static-site.name
  location            = "westeurope"

  origin_host_header = azurerm_storage_account.static-site.primary_web_host

  is_http_allowed        = true
  is_compression_enabled = true

  content_types_to_compress = [
    "text/plain",
    "text/html",
    "text/css",
    "text/javascript",
    "application/x-javascript",
    "application/javascript",
    "application/json",
    "application/xml"
  ]

  delivery_rule {
    name  = "httpRedirect"
    order = 1
    request_scheme_condition {
      operator     = "Equal"
      match_values = ["HTTP"]
    }

    url_redirect_action {
      redirect_type = "PermanentRedirect"
      protocol      = "Https"
    }
  }

  delivery_rule {
    name  = "wwwRedirect"
    order = 2
    request_uri_condition {
      operator     = "BeginsWith"
      match_values = ["https://www."]
      transforms   = "Lowercase"
    }

    url_redirect_action {
      redirect_type = "PermanentRedirect"
      protocol      = "Https"
      hostname      = "https://james.cx"
    }
  }

  origin {
    name      = azurerm_storage_account.blog.name
    host_name = azurerm_storage_account.blog.primary_web_host
  }
}

Some notes about the above block:

DNS

The last part of the Terraform is to set up the DNS in dns.tf. We will set up 2 CNAME records for the www subdomain and the “apex” or “root” domains to point towards our CDN Endpoint:

resource "azurerm_dns_zone" "jamescx" {
  name                = "james.cx"
  resource_group_name = azurerm_resource_group.static-site.name
}

resource "azurerm_dns_cname_record" "www" {
  name                = "www"
  zone_name           = azurerm_dns_zone.jamescx.name
  resource_group_name = azurerm_resource_group.static-site.name
  ttl                 = 300
  target_resource_id  = azurerm_cdn_endpoint.static-site.id
}

resource "azurerm_dns_a_record" "apex" {
  name                = "@"
  zone_name           = azurerm_dns_zone.jamescx.name
  resource_group_name = azurerm_resource_group.static-site.name
  ttl                 = 300
  target_resource_id  = azurerm_cdn_endpoint.static-site.id
}

Creating the Resources (Terraform Cloud)

Now that we have our Terraform files, we need to get Terraform Cloud to create the resources into our Azure Account. The setup for this is best seen on the Terraform provider or the Microsoft Docs sites.

There is currently a manual step that will need to do with the CDN, which is to link up the domain and certificate. This is a missing feature (at time of writing) of the Terraform provider. Once the resources have been created in Azure, do these steps:

You will have to wait a while whilst the certificate is provisioned for you.

For the apex or root domain, there is an additional (and rather annoying) step. Azure will not (at time of writing) cerate you a free apex certificate so you will have to source one yourself. You can either do this for free (Let’s Encrypt) or purchase your own (I recommend Namecheap). The certificate can then be uploaded to a KeyVault within Azure and used from the CDN.

Github Flows

Lastly, we will get Github Flows to build and deploy the static site to the created Azure Resources.

Create the file .github/workflows/build-blog.yml with the blow contents:

name: Build & Release Blog
on:
  push:
    branches:
    - main
    
jobs:
  build_blog:
    runs-on: ubuntu-latest
    steps:
     - name: CHECKOUT
       uses: actions/checkout@v2
      
     - name: AZURE LOGIN 
       uses: azure/login@v1
       with:
         creds: $
         
     - name: Upload to blob storage
       uses: azure/CLI@v1
       with:
         azcliversion: 2.0.72
         inlineScript: |
             az storage blob upload-batch --account-name staticsitestorage -d '$web' -s ./src
             
     - name: Purge CDN endpoint
       uses: azure/CLI@v1
       with:
         azcliversion: 2.0.72
         inlineScript: |
            az cdn endpoint purge --content-paths  "/*" --profile-name "static-site-cdn" --name "static-site-cdnep" --resource-group "static-site"

     - name: logout
       run: |
            az logout

The steps do this: login to Azure, upload the static files and then purge the CDN cache so that the new files are visible ASAP.

You will also need to setup the deployment credentials within Github. Details for this can be found on the Github Marketplace site.

Conclusion

And that should be it! You have now setup a static site and workflow such that when you push a change to your main branch, the files will appear on your domain.