How this site was built

I wanted to document how this site was built as it’s interesting to me, it includes some web knowledge, Terraform IaC, Azure PaaS services, DNS and custom domains, GitHub and Actions.

The site itself

This is a static website using HUGO and the Docsy theme. I won’t go into detail about how this tech works as the websites below go into far better detail. But in short, I cloned the example site to my personal GitHub repo, then on my Windows workstation I cloned the repo locally so I could edit the configuration and add content via Visual Studio Code, with any changes checked back in GitHub.

Hugo

Docsy

Azure Static Web App

I use Azure Static Web Apps to host this site for two principal reasons. First, it has a free tier which is perfect for my consumption model. Secondly, I can integrate GitHub Actions into a CI/CD workflow so I can automate the publishing of new content by simply pushing new data to the GitHub repository. I’ll cover this action later.

I won’t detail how Terraform works as there are too many options and it’s not that relevant here. But I wil post my module and configuration code examples so you can be inspired.

This is the module code for the resource group

locals {
  tags = {
    app = var.prefix
    env = var.environment
  }
}

resource "azurerm_resource_group" "this" {
  name     = "rg-${var.prefix}-${var.environment}"
  location = var.resource_group_location
  tags     = local.tags
}
variable "resource_group_location" {
  type        = string
  description = <<EOT
  (Required) Location of the resource group where the workload will be managed

  Options:
  - westeurope; west europe
  - eastus; east us
  - southeastasia; south east asia
  EOT

  validation {
    condition     = can(regex("^westeurope$|^eastus$|^southeastasia$|^west europe$|^east us$|^south east asia$", var.resource_group_location))
    error_message = "Err: location is not valid."
  }
}

variable "environment" {
  type        = string
  description = <<EOT
  (Required) Describe the environment type

  Options:
  - dev
  - test
  - prod
  EOT

  validation {
    condition     = can(regex("^dev$|^test$|^prod$", var.environment))
    error_message = "Err: Valid options are dev, test, or prod."
  }
}

variable "prefix" {
  description = "(Required) Name of the workload"
  type        = string
}
output "resource_group_name" {
  description = "The name of the resource group"  
  value = azurerm_resource_group.this.name
}

output "resource_group_location" {
  description = "The location of the resource group"
  value = azurerm_resource_group.this.location
}

This is the module code for the static app

locals {
  tags = {
    app = var.prefix
    env = var.environment
  }
}

resource "azurerm_static_site" "this" {
  name                = "stapp-${var.prefix}-${var.environment}"
  resource_group_name = var.resource_group_name
  location            = var.resource_group_location
  sku_size            = var.sku_size
  tags                = local.tags
}
variable "resource_group_name" {
  type        = string
  description = "(Required) Name of the resource group where the workload will be managed"
}

variable "resource_group_location" {
  type        = string
  description = <<EOT
  (Required) Location of the resource group where the workload will be managed

  Options:
  - westeurope; west europe
  - eastus; east us
  - southeastasia; south east asia
  EOT

  validation {
    condition     = can(regex("^westeurope$|^eastus$|^southeastasia$|^west europe$|^east us$|^south east asia$", var.resource_group_location))
    error_message = "Err: location is not valid."
  }
}

variable "environment" {
  type        = string
  description = <<EOT
  (Required) Describe the environment type

  Options:
  - dev
  - test
  - prod
  EOT

  validation {
    condition     = can(regex("^dev$|^test$|^prod$", var.environment))
    error_message = "Err: Valid options are dev, test, or prod."
  }
}

variable "prefix" {
  description = "(Required) Name of the workload"
  type        = string
}


variable "sku_size" {
  type        = string
  description = <<EOT
  (Optional) Set the hosting plan
  
  Free is recommened For hobby or personal projects.
  Standard is or general purpose production apps
  
  Options:
  - Free
  - Standard

  EOT

  default = "Free"

  validation {
    condition     = can(regex("^Free$|^Standard$", var.sku_size))
    error_message = "Err: Valid options are Free, Standard."
  }
}
output "static_app_deployment_token" {
  description = "This token is used by deployment workflows to authenticate with the Static Web App."
  value       = azurerm_static_site.this.api_key
}

This is the configuration code.

terraform {
  required_version = ">= 1.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "3.43.0"
    }
  }
}

provider "azurerm" {
  features {}
}
locals {
  prefix      = "example-website"
  location    = "west europe"
  environment = "dev"
}
module "resource_group" {
  source  = "app.terraform.io/resource_group/azurerm"
  version = "~>0.1.0"
  # insert required variables here
  prefix                  = local.prefix
  resource_group_location = local.location
  environment             = local.environment
}

output "resource_group_name" {
  description = "The name of the resource group"
  value       = module.resource_group.resource_group_name
}

output "resource_group_location" {
  description = "The location of the resource group"
  value       = module.resource_group.resource_group_location
}
module "static_site" {
  source  = "app.terraform.io/static_site/azurerm"
  version = "~>0.1.0"
  # insert required variables here
  prefix                  = local.prefix
  environment             = local.environment
  resource_group_name     = module.resource_group.resource_group_name
  resource_group_location = module.resource_group.resource_group_location
  sku_size                = "Free"
}

output "static_app_deployment_token" {
  description = "This token is used by deployment workflows to authenticate with the Static Web App."
  value       = module.static_site.static_app_deployment_token
  sensitive   = true

}

At this point, you will have an empty but functioning website. The default landing page will be available on a randomized URL with SSL all configured for you. The kind of URL string could be something like https://green-smoke-0e35ce303.2.azurestaticapps.net/ with the key part being the TLD of azurestaticapps.net which is where all the static apps are hosted. We can use our own domain name, which I’ll cover later.

Web App Deployment Token

In the GitHub repository where your website is you need to create a secret called AZURE_STATIC_WEB_APPS_API_TOKEN with the value of your static web app deployment token.

Reset deployment tokens in Azure Static Web Apps

Create the GitHub Workflow

In the GitHub repository create a new file in .github/workflows with a suitable name, for example, deploy-web-app-to-azure.yaml. On the referenced GitHub webpage you can copy their provided workflow direclty into your new file. It’s probably 99% ready to go. For my setup, I had to modify the workflow provided by the GitHub help page to solve some errors with Hugo and the output location.

Error using the direct copy from GitHub

image

These code samples should demonstrate what I had to modify to solve the error.

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.

name: Deploy web app to Azure Static Web Apps

env:
  APP_LOCATION: "/" # location of your client code
  API_LOCATION: "api" # location of your api source code - optional
  APP_ARTIFACT_LOCATION: "build" # location of client code build output

on:
  push:
    branches:
      - main
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - main

permissions:
  issues: write
  contents: read

jobs:
  build_and_deploy:
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    name: Build and Deploy
    steps:
      - uses: actions/checkout@v3
        with:
          submodules: true
      - name: Build And Deploy
        uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: "upload"
          app_location: ${{ env.APP_LOCATION }}
          api_location: ${{ env.API_LOCATION }}
          app_artifact_location: ${{ env.APP_ARTIFACT_LOCATION }}

  close:
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Close
    steps:
      - name: Close
        uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          action: "close"
// Added the following line to the bottom of the ENV variables

OUTPUT_LOCATION: "public"

// Add the following to the 'Build and Deploy' job

output_location: ${{ env.OUTPUT_LOCATION }}
// Added the following job to install Hugo dependency

# Sets up Hugo
  - name: Setup Hugo
    uses: peaceiris/actions-hugo@v2
      with:
        hugo-version: '0.63.2'

// Added the following command to build the site to the 'Build and Deploy' job

app_build_command: hugo --minify
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.

name: Deploy web app to Azure Static Web Apps

env:
  APP_LOCATION: "/" # location of your client code
  API_LOCATION: "api" # location of your api source code - optional
  APP_ARTIFACT_LOCATION: "build" # location of client code build output
  OUTPUT_LOCATION: "public"

on:
  push:
    branches:
      - main
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - main

permissions:
  issues: write
  contents: read

jobs:
  
  build_and_deploy:
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    
    name: Build and Deploy
    steps:
      
      # Sets up Hugo
      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: '0.63.2'
      
      - uses: actions/checkout@v3
        with:
          submodules: true
      
      - name: Build And Deploy
        uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: "upload"
          app_location: ${{ env.APP_LOCATION }}
          api_location: ${{ env.API_LOCATION }}
          output_location: ${{ env.OUTPUT_LOCATION }}
          app_build_command: hugo --minify
  close:
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Close
    steps:
      - name: Close
        uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          action: "close"

When you get a workflow to execute successfully you can see the code being imported to the Azure Static Web App in the portal such as below.

image

At this point, you will have a functioning website with content that is automatically uploaded when you make changes in GitHub. But the URL still is the Azure default which for you may be fine, if which case you’re pretty much done. But, if you want to personalize your site then follow the remaining steps.

Deploying to Azure Static Web App

Custom domain name

Again, I won’t get into details, but you need your public domain name and hosting provider with DNS services you can edit. If you want to use Azure DNS then go for it but I don’t so this will detail using my process. But the principle is the same, in that your create a DNS record in your domain that people use to ht your site on the public Internet, this record will then re-direct to the Azure website’s domain to reach your site.

Domain ownership validation

In the Azure portal, navigate to the website, and select the Custom domains section. Here you’ll see your default URL. From the Add menu choose Custom Domain on other DNS. Enter your domain name in the Domain name text box and select next. You choose to create a validation reference as either TXT or CNAME, so check which one your DNS hosting partner supports, and select generate. Create the DNS record with the value provided by Azure and wait for it to be validated.

For example, a TXT record would be created

Type: TXT

Host: @

Value: sdfdasdgfasgimasdgmkgasdgdkmasdgkm

Add a www.domain.com

If you want to use a www.domain.com DNS record then in the Azure portal, in the Custom domains section select Add then enter the FQDN such as www.domain.com and select next. The wizard will provide you with details to add to the DNS zone.

Type: CNAME

Host: www

Value: green-smoke-0e35ce303.2.azurestaticapps.net