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.
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:
- Install Terraform providers as defined in the
providers.tf
file.
- Produce a verbose plan of the intended deployment actions.
- 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.

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:
- Create the Resource Group + Static Web App.
- (Optional): Create CNAME DNS entry in Cloudflare, mapping the default DNS name provided by Azure to your chosen custom domain name.
- Read the deployment token from the created Azure Web App.
- 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.


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
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 }}
|