
Deploying a Nuxt Static Website on Azure with Pulumi
The other day, to learn and experiment with GitHub Copilot Agent mode, I built a small website in Nuxt that provides an interactive calendar view of developer conferences around the world. I was happy with the result and I wanted to deploy it on Azure. So I created a Pulumi program in TypeScript to provision an Azure Static Web App, configure a custom domain, and retrieve a deployment token I could use to deploy the website in my GitHub Actions pipeline.
For that, I've used:
- the version 3 of the Azure Native provider that has just been released
- the new customizable resource auto-naming in Pulumi to easily set up a naming convention for my resources
- a Pulumi ESC environment configured to deploy to Azure using OpenID Connect
- Pulumi Copilot Visual Studio Code extension to help me with the infrastructure code
My Journey
Context
The website I built is inspired by the developers.events website, which is the official website of the open source developers-conferences-agenda
GitHub repository. This repository, which everyone can contribute to, provides data about developer conferences and CFPs worldwide. So I used this data to develop a Nuxt application that people can use to find the events taking place at a specific date in different countries.
Here is the website: https://devconferences.techwatching.dev/.
In this article I want to focus on the work I did to provision the cloud Infrastructure I needed and deploy the application on it.
Choices
To host a website on Azure, there are several options available, such as a storage account, app service, container app, or static web app. However, for a static website, I believe the best choice is an Azure Static Web App, which is specifically designed for this type of scenario.
My website was developed in Nuxt using TypeScript so it made sense to choose TypeScript to develop my infrastructure code too. Using Pulumi in TypeScript with the Azure Native provider was a natural choice. Since a new version of the Azure Native provider has just been released, it was the opportunity to test it.
My code repository is hosted in GitHub so I wanted to use GitHub Actions to provision the infrastructure and deploy the application.
Prompting My Way to Pulumi
I’ve used a lot GitHub Copilot for the application code, no reason not to do the same with the infrastructure code, especially when there is a Pulumi Copilot extension for vscode that lets you use an IA assistant for Pulumi directly in GitHub Copilot Chat.
My first prompt was the following:
@pulumi I want to create a pulumi program in TypeScript the infra folder for the website I'm developing here.
The program should use the azure native provider to create an azure static web app.
It made me create run the command to create the pulumi program, add the Azure Native provider and provided me with an initial version of the Azure Static Web App infrastructure code, which I could modify to suit my needs.
pulumi new typescript
pnpm add @pulumi/azure-native
import * as azure from '@pulumi/azure-native'
const resourceGroup = new azure.resources.ResourceGroup('devconfs')
const staticWebApp = new azure.web.StaticSite('devconfs-site', {
resourceGroupName: resourceGroup.name,
location: resourceGroup.location,
sku: {
name: 'Free',
tier: 'Free'
},
buildProperties: {}
})
The only downside of Pulumi Copilot is that it’s not yet available in Edit or Agent modes. When you're accustomed to using Edit and Agent modes, relying solely on the Ask mode doesn't feel sufficient.
You can notice I removed the buildProperties
and some other properties when defining the Azure Static Web App. It’s because, I did not want Azure to automatically create a build and deploy pipeline in my GitHub repository, but instead I wanted to fully control this process from a CI/CD pipeline I would create manually.
Adopting A Naming Convention Thanks To Pulumi Auto-Naming
When working with cloud infrastructure, it’s important to define a naming convention for your resources and enforce it in your infrastructure code. The Azure documentation is a good starting point to learn about the recommended patterns and the naming limitations for some resource types (restricted characters or maximum length for names).
In addition to the naming convention, it’s important to have unique names to avoid conflicts between environments and deployments (different instances, regions, etc). Even if your resource names include the name of the environment or region, it's a good idea to add some randomness to them. It’s also very important to allow zero-downtime deployment, when a change involves deleting a resource and recreating a new one (mandatory on some resources when changing some specific properties).
Pulumi has always automatically handled that by appending a few random characters at the end of the provided name for a resource. With that auto-naming feature, when a resource needs to be replaced, Pulumi ensures the new resource is created before deleting the old one. What they recently released is the ability to configure this auto-naming feature and specify a custom naming pattern to use globally or by resource type.
For my projet, I configured auto-naming to use the following naming pattern ${name}-${stack}-${hex(8)}
by default and specify custom patterns for some resource types (to add a specific prefix relative to the type of resource) like stapp-${name}-${stack}-${hex(8)}
for the Static Web App. This gave me the following project configuration file:
name: developer-conferences
description: The infrastructure for the Developer Conferences website
runtime:
name: nodejs
options:
packagemanager: pnpm
config:
pulumi:autonaming:
value:
pattern: ${name}-${stack}-${hex(8)}
providers:
azure-native:
resources:
"azure-native:resources:ResourceGroup":
pattern: rg-${name}-${stack}-${hex(8)}
"azure-native:web:StaticSite":
pattern: stapp-${name}-${stack}-${hex(8)}
You can see the generated names for the resources:
I used a simple naming convention, but there are many options depending on your needs. In the future, I might create a Pulumi ESC environment called naming-conventions
with all the patterns by resource type that I use, especially for those with naming restrictions. This way, by importing this environment into my other ESC environments or directly into my IaC project configurations, all resources in my organization's projects will automatically follow the same naming conventions.
Simplifying and Securing Deployment to Azure with Pulumi ESC
Speaking of ESC, I set up a new ESC environment to allow my Pulumi project to authenticate easily and securely to Azure using OpenID Connect, both locally and from my GitHub Actions pipeline. I created this new environment developer-conferences/prod
directly from the Pulumi vscode extension.
To authenticate to Azure, using a Service Principal with a client secret that can expire or be compromised is not the best practice. You should instead rely on Workoad Identity Federation that uses OIDC like I explain in this article. This requires to do some configuration in Azure Entra ID to authorize a GitHub Actions (or Azure DevOps) pipeline from a specific repository to retrieve an Azure access token and perform some actions on an Azure subscription. All that is a bit cumbersome (although it can be automated using Azure CLI or Pulumi) and will not work locally.
This is where Pulumi ESC comes in: once properly configured an ESC environment allows us to authenticate with cloud providers using OpenID Connect tokens. I already had an ESC environment azure-authentication/visual-studio-enterprise
configured to authenticate to my Visual Studio Enterprise
Azure subscription. All I had to do was to import this environment in my new developer-conferences/prod
environment and use this environment in my Pulumi project.
I used the following command to use the ESC environment in my current stack but you can also directly manually edit the stack configuration file.
pulumi config env add developer-conferences/prod
environment:
- developer-conferences/prod
config:
azure-native:location: westeurope
My Pulumi project could then provision resources on my Azure subscription as long as it was authenticated with Pulumi Cloud. Locally, I just needed to be logged into the Pulumi CLI. In my GitHub Actions pipeline, I first had to set up the OpenID Connect integration for GitHub in my Pulumi organization.
Here is the resulting GitHub Actions pipeline part for the Pulumi part (no secret is needed for it work):
name: Deploy to Azure Static Web App
on:
push:
branches:
- main
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Install infra dependencies
run: pnpm install --dir infra
- uses: pulumi/auth-actions@v1
with:
organization: TechWatching
requested-token-type: urn:pulumi:token-type:access_token:personal
scope: user:TechWatching
- uses: pulumi/actions@v6
with:
command: up
stack-name: techwatching/developer-conferences/prod
work-dir: infra
Deploying the Website Using a Secret Stack Output
To the best of my knowledge, a front-end application can only be deployed on an Azure Static Web App using a deployment token. I could get this token from my provisioned Azure Static Web App resource and store it in the GitHub Action secrets, but that wouldn't be ideal: if I delete my resource or create a new environment, the deployment won’t work.
Instead, I let my infrastructure code retrieve the generated deployment token and set it as a stack output. This way, I can easily access the token from my stack outputs, and if my infrastructure changes, the stack output will update accordingly.
const staticSecretOutputs = azure.web.listStaticSiteSecretsOutput({
resourceGroupName: resourceGroup.name,
name: staticWebApp.name
})
export const staticWebAppDeploymentToken = pulumi.secret(staticSecretOutputs.properties.apply(p => p.apiKey))
I used the pulumi.secret
function so that the staticWebAppDeploymentToken
is marked as secret and automatically encrypted because it contains sensitive data.
I asked Pulumi Copilot to adjust my GitHub Actions workflow accordingly:
The answer was not perfect, it used an old syntax ::set-output
and did not ensure the token was written in the logs. So I had to adjust the suggestion a bit, but at least it pointed me in the right direction. Here's what it looks like using the secret stack output for the deployment of the website:
- uses: pulumi/actions@v6
with:
command: up
stack-name: techwatching/developer-conferences/prod
work-dir: infra
# Securely get the deployment token from Pulumi outputs
- name: Get Static Web App Deployment Token
id: get-token
run: |
# Use Pulumi CLI to get the token and mask it in logs
TOKEN=$(cd infra && pulumi stack output staticWebAppDeploymentToken --show-secrets)
echo "::add-mask::$TOKEN"
echo "deployment_token=$TOKEN" >> $GITHUB_OUTPUT
- name: Deploy to Azure Static Web App
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ steps.get-token.outputs.deployment_token }}
action: "upload"
app_location: ".output/public" # The location of the Nuxt generated static files
skip_app_build: true # We've already built the app
skip_api_build: true
To deploy the website from you local environment using the the CLI, you could have used the following command:
pnpx @azure/static-web-apps-cli deploy .output/public --env production --deployment-token (pulumi stack output staticWebAppDeploymentToken --show-secrets --cwd infra)
Using a Custom Domain for the Website
Configuring a custom domain on the Azure Static Web App was straightforward. I simply used the StaticSiteCustomDomain
resource and specified the custom domain I owned.
new azure.web.StaticSiteCustomDomain('customDomain', {
resourceGroupName: resourceGroup.name,
name: staticWebApp.name,
domainName: 'devconferences.techwatching.dev'
})
The devconferences.techwatching.dev
is a subdomain I manually created using Netlify DNS. I could have used the Pulumi Netlify provider for this, and it’s something I might do in the future. I just haven't taken the time to do it yet.
Final Thoughts
As always, working with Pulumi was a great experience. Deploying a static website on Azure using Pulumi is quite simple. I was able to leverage advanced features of Pulumi, such as customizable auto-naming and Pulumi ESC, to meet my needs for naming convention and secure deployments. Pulumi vscode extensions helped me along the way, and Pulumi Copilot is nice, but I'm looking forward to it being upgraded to work with the latest features of GitHub Copilot (Edit and Agent Mode, Model Context Protocol, etc.).
You can find the complete code here, don’t hesitate to give it a start or make a comment here. Keep learning.