Featured image of post Deploy Hugo Website with Azure Static Web Apps, Terraform and Github Actions

Deploy Hugo Website with Azure Static Web Apps, Terraform and Github Actions

Deploy a Hugo static site to Azure Static Web Apps using Terraform and Github Actions.

Overview

This article provides the steps required to deploy a website with its codebase stored in Github, into Azure Static Web Apps using Terraform and Github Actions. This method uses Terraform to define and deploy the infrastructure resources, and implements CI/CD using a GitHub Actions workflow.

At the time of this article, Azure Static Web Apps are free of charge under the “free” plan and come with valid SSL certificates at no cost. Static Web Apps can be assigned custom domains via a 3rd party vendor, such as Cloudflare.

NOTE: This guide uses a custom domain name with DNS provided by Cloudflare, however this process can be used without a custom domain by removing the relevant variables and lines within the Terraform resource definitions.

Requirements

  • Familiarity with command line, Git and development concepts.
    • Check out this article on Git for more information.
  • An existing web application (for example, created using a Static Site Generator).
  • Github account containing the above web app codebase.
  • Existing Azure tenant and subscription.
  • Azure CLI installed.
  • Terraform installed.
  • Github CLI installed.
  • (Optional): Custom domain name.
  • (Optional): Cloudflare API token for DNS - see guide here.

Resources To Be Created

  • Azure Resource Group (RG)
    • Logical container used to “house” the project resources.
  • Azure Static Web App (SWA)
    • Static web site served, without the requirement for a backend/database configuration.
  • Deployment token
    • Exported from SWA and stored as a GitHub Secret.
  • GitHub Actions
    • Workflow to build the Hugo site and deploy the infra resources when commits or pull requests are merged into the main branch of the repo.
  • Cloudflare DNS
    • DNS CNAME record in for custom domain name.

Terraform Code

The following files need to be created and populated. Refer to the end of the article to locate the completed example files containing all parameters and resources.

  • providers.tf
    • Define the Terraform providers and version configuration.
  • main.tf
    • Primary configuration file for defining resources and logic.
  • variables.tf
    • Declare input variables, defining the name and type.
  • terraform.tfvars
    • This file contains variable value inputs, including potential secrets (API tokens etc).
    • Ensure that this file is never committed to the Git repo.
  • outputs.tf
    • Declares output values to display after terraform apply (IPs, connection strings).
    • Useful for referencing in other modules.

Deploy to Azure

Ensure you are logged into Azure CLI as this is leveraged by the Terraform executable. By default (without changes to structure), executing this Terraform code will perform the following actions:

  1. Install Terraform providers as defined in the providers.tf file.
  2. Produce a verbose plan of the intended deployment actions.
  3. Deploy resources as defined in the main.tf file.
1
2
# Authenticate to Azure via AzureCLI.
az login

Execute Terraform to initialize the working directory, installing any required providers, run the plan for review before executing Terraform finally to deploy resources as per configuration defined within the Terraform files.

  • NOTE: If using a custom domain, it is possible that Azure CNAME validation may take anywhere from 5-10 minutes to complete. Due to this, a sleep timer has been added for 300 seconds to allow enough time for Azure to complete the validation, before attempting to
1
2
3
4
5
6
# Initialize Terraform, run a plan to review.
terraform init -upgrade
terraform plan

# Instruct Terraform to apply the configuration.
terraform apply

Output:

1
2
3
4
5
6
7
8
9
azurerm_static_web_app.swa: Still creating... [00m20s elapsed]
azurerm_static_web_app.swa: Creation complete after 21s [id=/subscriptions/123456-abcd-1234-abcd-1a2b3c4d5e6f/resourceGroups/abc-website-prd-rg/providers/Microsoft.Web/staticSites/abc-website-prd-app]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

swa_default_hostname = "made-up-stuff-0c260881e.1.azurestaticapps.net"
swa_deployment_token = <sensitive>

At this stage, you should now see the resources created in Azure under the subscription.

Azure resources, created by Terraform.

Automate Deployment with Github Actions

GitHub Actions is a CI/CD (Continuous Integration and Continuous Delivery/Deployment) platform built into GitHub. It allows automation workflows directly in a repository, such as building, testing, and deploying code. Triggers can be based on events like pushes, pull requests, or scheduled triggers.

Add SWA Deployment Token to Github Actions

We can configure Terraform to provision the Static Web App resources and push its deployment token directly into the GitHub Actions secrets within the repo. This removes the requirement to manually add the deployment secret to Github.

  • NOTE: Requires additions/modifications to the following files:
    • providers.tf
    • variables.tf
    • terraform.tfvars
    • main.tf

providers.tf

Add the github provider under the required_providers section, including the owner comfiguration line.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
terraform {
  required_version = ">= 1.5.0"
  required_providers {
	# Add
    github = {
      source  = "integrations/github"
      version = "~> 6.0"
    }
  }
}

# Add
provider "github" {
  owner = var.github_org_user
}

variables.tf

Add the variable github_org_user to the variables.tf file so it can be passed into the providers.tf file as above.

1
2
3
4
# Add
variable "github_org_user" {
    type = string
}

terraform.tfvars

Provide a value for the github_org_user variable in the terraform.tfvars file.

1
2
# Add
github_org_user = "my-username"

main.tf

Add the Github resource as below to define the secret to be added to the existing repo.

1
2
3
4
5
6
# Add
resource "github_actions_secret" "swa_token" {
  repository      = var.github_repo_name
  secret_name     = "AZURE_SWA_TOKEN"
  plaintext_value = azurerm_static_web_app.swa.api_key
}

Run the Terraform commands again to push the changes (deployment token added to Github). Terraform will perform the following actions:

  1. Create the Resource Group + Static Web App.
  2. (Optional): Create CNAME DNS entry in Cloudflare, mapping the default DNS name provided by Azure to your chosen custom domain name.
  3. Read the deployment token from the created Azure Web App.
  4. Push the token directly into the GitHub repo Actions secrets. This can then be read in as a variable by the workflow later.
1
2
3
4
# Execute Terraform again tio add the deployment token into Github Actions.
terraform init -upgrade
terraform plan
terraform apply

Github Actions Workflow

This component is responsible for automating the build and deployment of the website, with steps defined in a YAML file. The deployment token added in the previous step will be referenced in the YAML code and used by the pipeline during the deployment steps.

Process Flow:

  • GitHub Action:
    • Installs Hugo.
    • Build site from src/ into public/.
    • Deploy to Static Web App using the deployment secret from received in Terraform.
  • SWA serves the Hugo site in Azure SWA.

Create the below file azure_swa.yml and add it into the repository under .github\workflows. If this directory doesn’t already exist, create it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
name: Deploy Hugo site to Azure Static Web Apps

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

jobs:

  build_and_deploy:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    steps:
      # Checkout your repo
      - name: Checkout
        uses: actions/checkout@v4
      # Install Hugo
      # You can pin to a specific Hugo version here
      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: 'latest' # replace with latest stable if needed
          extended: true         # needed if using Hugo Pipes/SASS
      # Build the Hugo site
      - name: Build
        run: hugo -s ./src --minify
      # Deploy to Azure Static Web Apps
      - name: Deploy to Azure Static Web Apps
        uses: Azure/static-web-apps-deploy@v1
        with:
          action: upload
          azure_static_web_apps_api_token: ${{ secrets.AZURE_WWWTSHANDCOM }}
          # app_location is where your Hugo config.toml is
          app_location: "/src"
          # No API folder for Hugo static site
          api_location: ""
          # output_location is Hugo's public/ directory
          output_location: "public"

  close_pull_request_job:
    if: github.event_name == 'pull_request' && github.event.action == 'closed' # Close pull request, if PR was the trigger.
    runs-on: ubuntu-latest
    name: Close Pull Request Job
    steps:
      - name: Close Pull Request
        id: closepullrequest
        uses: Azure/static-web-apps-deploy@v1
        with:
          app_location: ""
          action: "close"
          azure_static_web_apps_api_token: ${{ secrets.AZURE_WWWTSHANDCOM }}

Ensure the workflow is committed to the repo for it to take affect. This workflow will run the Hugo site build process and deploy the generated website code to the Azure Static Web App using the deployment token generated during the Terraform deployment process.

Github actions workflow.

Github Actions job run.

Final Steps

With the website codebase and Github Actions workflow now committed to the repository, whenever future commits or pull requests are merged, the Hugo deployment process will be initiated. This will re-build the Hugo static site from the /src directory, with the output stored in the /public directory. The contents of the /public directory are what will be published to via the Azure Static Web App.


Example Files

Terraform

providers.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    # Used for Azure resources.
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    # OPTIONAL: Used for Cloudflare resources (DNS, domains, etc).
    cloudflare = {
      source = "cloudflare/cloudflare"
      version = "5.8.2"
    }
    # Used for generating time-based resources, used for timestamps in names or tags.
    time = {
      source = "hashicorp/time"
      version = "0.13.1"
    }
    # Used for GitHub resources, such as repositories, uploading secrets to Github Actions.
    github = {
      source  = "integrations/github"
      version = "~> 6.0"
    }
  }
}
# Provider configurations.
# These are the providers that will be used in the Terraform configuration.
provider "azurerm" {
  subscription_id = var.azure_sub_id # Replace the associated variable with your Azure subscription ID.
  tenant_id = var.azure_tenant_id # Replace the associated variable with your Azure tenant ID.
  features {}
}
provider "cloudflare" {
  api_token = var.cloudflare_api_token # Replace the associated variable with your Cloudflare API token.
}
provider "github" {
  owner = var.github_org_user # Replace the associated variable with your GitHub org/user.
}

outputs.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Output the hostname assigned to the SWA.
output "swa_default_hostname" {
  value = azurerm_static_web_app.swa.default_host_name
}

# Deployment token to be used by GitHub Actions workflow to deploy the SWA in Azure.
output "swa_deployment_token" {
  value     = azurerm_static_web_app.swa.api_key
  sensitive = true # To view: terraform output -raw swa_deployment_token
}

variables.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
### Variables ###

# Azure
variable "location_pref" {
    type = string
    description = "Default/preferred location for resource deployment."
}
variable "azure_tenant_id" {
    type = string
    description = "Azure tenant ID, used for auth."
}
variable "azure_sub_id" {
    type = string
    description = "Azure subscritpion ID, used for resource deployment."
}

# Github
variable "github_repo_url" {
    type = string
    description = "URL of the GitHub repository where the Hugo site is stored."
}
variable "github_repo_name" {
    type = string
    description = "Name of the GitHub repository where the Hugo site is stored."
}
variable "github_repo_branch" {
    type = string
    description = "Branch of the GitHub repository where the Hugo site is stored."
}
variable "github_org_user" {
    type = string
    description = "GitHub organization or user name where the repository is stored."
}

# Cloudflare (comment out this block if not required).
variable "custom_domain_name" {
  type = string
  description = "OPTIONAL: Custom domain name to be used for the Azure Static Web App."
}
variable "cloudflare_zone_id" {
  type = string
  description = "Cloudflare zone ID for the custom domain."
}
variable "cloudflare_api_token" {
  type = string
  description = "Cloudflare API token with permissions to manage DNS records."
}
variable "cloudflare_dnshost" {
  type = string
  description = "Cloudflare DNS host for the custom domain."
  default = "www" # Default to 'www' subdomain, can be overridden.
}

# Naming Conventions (using validations).
variable "project_prefix" {
  type        = string
  description = "Core naming prefix for majority of resources."
  validation {
    condition     = length(var.project_prefix) == 3 # Must be exactly 3 characters
    error_message = "The org_pefix must be exactly 3 characters long."
  }
}
variable "project_name" {
  type        = string
  description = "Project code for naming convention."
}
variable "project_environment" {
  type        = string
  description = "Environment code for naming convention (prd, dev, tst)."
  validation {
    condition     = contains(["prd", "dev", "tst"], var.project_environment)
    error_message = "Valid value is one of the following: prd, dev, tst."
  }
}
variable "tag_creator" {
  type        = string
  description = "Name of account/user creating resources."
}
variable "tag_owner" {
  type        = string
  description = "Name of account/user creating resources."
}

terraform.tfvars

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
### Variables ###
# Azure
azure_tenant_id = "12345-abcd-1234-efgh-1234567890"
azure_sub_id = "12345-abcd-1234-efgh-1234567890"
location_pref = "westus2" # SWA Limited to specific regions.

# Cloudflare: https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
cloudflare_zone_id = "1234567890"
cloudflare_api_token = ""
cloudflare_dnshost = "www"

# Naming Conventions (using validations)
project_prefix = "abc"
project_name = "websitecom"
project_environment = "prd"
tag_creator = "Terraform"
tag_owner = "CloudOps"

# Github
github_repo_url = "https://github.com/my-github/website"
github_repo_branch = "main"
custom_domain_name = "www.website.com"

main.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
### Main ###
# Local variables for resource tags and timestamps.
locals {
  # Timestamp for tag 'Created'.
  timestamp = replace(replace(replace(replace(timestamp(), "-", ""), "T", ""), ":", ""), "Z", "")
}

# Azure: Resource group for the project.
resource "azurerm_resource_group" "rg" {
  name          = "${var.project_prefix}-${var.project_name}-${var.project_environment}-rg"
  location      = var.location_pref
  tags = {
    Environment = var.project_environment
    Project     = var.project_name
    CreatedBy   = var.tag_creator
    Owner       = var.tag_owner
    Created     = local.timestamp
  }
}

# Azure: Static Web App
resource "azurerm_static_web_app" "swa" {
  name                  = "${var.project_prefix}-${var.project_name}-${var.project_environment}-app"
  resource_group_name   = azurerm_resource_group.rg.name
  location              = azurerm_resource_group.rg.location
  sku_tier              = "Free" # or "Standard"
  tags = {
    Environment = var.project_environment
    Project     = var.project_name
    CreatedBy   = var.tag_creator
    Owner       = var.tag_owner
    Created     = local.timestamp
  }
}

# Cloudflare: Add auto-generated SWA DNS name as CNAME record for custom domain check."
resource "cloudflare_dns_record" "record" {
  zone_id = "${var.cloudflare_zone_id}"
  name = "${var.custom_domain_name}"
  ttl = 60 # 60 seconds, can update later on.
  type = "CNAME" # Can be "A" or "TXT" depending on your setup.
  comment = "Azure - ${var.project_name} - Domain verification record" # Adds a comment for clarity.
  content = azurerm_static_web_app.swa.default_host_name # Supplied by Azure SWA resource after creation.
  proxied = false # Required to be 'false' for DNS verification.
}

# Sleep while DNS propagates (it can take a few minutes).
resource "time_sleep" "wait_for_dns" {
  create_duration = "180s"
  depends_on = [
    cloudflare_dns_record.record
  ]
}

# Azure: Add custom domain to SWA, after waiting for DNS.
resource "azurerm_static_web_app_custom_domain" "swa_domain" {
  static_web_app_id = azurerm_static_web_app.swa.id
  domain_name       = "${var.custom_domain_name}"
  validation_type   = "cname-delegation" # dns-txt-token
  depends_on = [
    time_sleep.wait_for_dns # Only deploy if this resource succeeds.
  ]
}

# GitHub Actions: Upload secret (deployment token) for the SWA.
resource "github_actions_secret" "swa_token" {
  repository      = var.github_repo_name
  secret_name     = "AZURE_SWA_TOKEN"
  plaintext_value = azurerm_static_web_app.swa.api_key
}

Github Actions (Workflow)

azure_hugo_static_webapp.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
name: Deploy Hugo site to Azure Static Web Apps
on:
  push:
    branches: [ "main" ] # Trigger on push to main branch.
  pull_request:
    types: [opened, synchronize, reopened, closed] # Trigger on pull request events.
    branches: [ "main" ] # Target main branch for PRs.
jobs:
  build_and_deploy:
    runs-on: ubuntu-latest # Use the latest Ubuntu runner.
    # Run on push or PR events except when PR is closed.
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    steps:
      # 1️⃣ Checkout the repo.
      - name: Checkout
        uses: actions/checkout@v4
      # 2️⃣ Install Hugo - can pin to a specific Hugo version here.
      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: 'latest' # Replace with latest stable if needed.
          extended: true         # Needed if using Hugo Pipes/SASS.
      # 3️⃣ Build the Hugo site.
      - name: Build
        run: hugo -s ./src --minify
      # 4️⃣ Deploy to Azure Static Web Apps.
      - name: Deploy to Azure Static Web Apps
        uses: Azure/static-web-apps-deploy@v1
        with:
          action: upload
          # Use the secret token for Azure Static Web Apps, uploaded using Terraform.
          azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_TOKEN }}
          # app_location is where your Hugo `config.toml` (or `hugo.yaml`) is.
          app_location: "/src"
          # No API folder for Hugo static site, VSCode has a cry if not specified.
          api_location: ""
          # Output_location is Hugo public/ directory.
          output_location: "public"
  close_pull_request_job:
    # This job runs only when a pull request is closed.
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Close Pull Request Job
    steps:
      - name: Close Pull Request
        id: closepullrequest
        uses: Azure/static-web-apps-deploy@v1
        with:
          app_location: "" # Not needed for closing PRs but again, VSCode has a cry if not specified.
          action: "close" # Action to close the pull request.
          azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_TOKEN }}

All rights reserved.
Built with Hugo
Theme Stack designed by Jimmy