Using Azure, Terraform and GitHub Actions to host an (almost) free static site
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?
- GitHub
- GitHub to store code (free)
- GitHub Flows to build and release the website (free)
- Terraform Cloud to create the infrastructure (free)
- Azure
- Azure Storage to store the built files (~£0.01 per GB)
- Azure CDN to serve the content (~£0.06 per GB)
- Azure CDN to manage our certificate (free)
- Azure DNS for the website DNS (~£0.40 per month)
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:
- We specifically allow HTTP traffic to our CDN, but then the first
delivery_rule
redirects all incoming HTTP requests to HTTPS. - We also set up a second redirection delivery rule to redirect all traffic starting with
www.
to our root (or apex) domain. More on this later.
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:
- Open up the CDN Endpoint in the Azure Portal
- Click "Add a Custom Domain"
- Enter the hostname for your
www
domain (e.g.www.james.cx
) and click Create - Click through to open up the custom domain settings
- Turn On the custom domain HTTPS
- Make sure CDN Managed and TLS 1.2 are selected
- Click Save
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.