Development·

Deploying a Nuxt Static Website on Azure with Pulumi

Provision the Azure infrastructure for the Developer Conferences website

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:

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/.

Calendar interface displaying developer conferences in April 2025, filtered by France and UK. Highlighted date is April 4, with conferences in London, Paris, and Orléans.

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
Thanks to the SDK size reduction, the Azure Native SDK was much faster to download.
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).

A scattered assortment of wooden Scrabble tiles shaping the word "random".

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:

Pulumi.yaml
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:

A screenshot of a table displaying a static web app with the name "stapp-devconfs-site-prod-5aac453f," located in West Europe. The resource group is listed as "rg-devconfs-prod-24fd3752.

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.

You can learn more about customizing auto-naming in the documentation or by watching this YouTube video on the topic.

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.

Screenshot of Pulumi Tools for Visual Studio Code extension page.

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.

Screenshot of the Pulumi ESC explorer in vscode with the developer-conferences/prod environment opened.

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
Pulumi.prod.yaml
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.

Screenshot of the OIDC Issuer page in Pulumi Cloud.

Here is the resulting GitHub Actions pipeline part for the Pulumi part (no secret is needed for it work):

deploy.yml
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:

Screenshot of a GitHub Copilot chat. User TechWatching asks Pulumi about modifying a pipeline to deploy infrastructure and obtain a deployment token. Pulumi responds with the steps to do that.

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:

deploy.yml
      - 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.


The opinions expressed herein are my own and do not represent those of my employer or any other third-party views in any way.

Copyright © 2025 Alexandre Nédélec. All rights reserved.