Deploy a React static website on Azure with Pulumi

Switch variant

Choose a different cloud or framework.

Deploy a production-shaped static website on Azure with a working blueprint app, a public URL, DNS delegation guidance, and a clear path to CI/CD after your first deployment.

Download blueprint

Get this Azure + React blueprint project as a zip. Switch Pulumi language here to keep the download aligned with the install commands and blueprint program on the page.

Download the TypeScript blueprint with the matching Pulumi program, dependency files, and README.

Download TypeScript blueprint

Download the Python blueprint with the matching Pulumi program, dependency files, and README.

Download Python blueprint

Download the Go blueprint with the matching Pulumi program, dependency files, and README.

Download Go blueprint

What this guide covers

This guide shows you how to build a small React site, publish the files from dist, and serve them from Azure through a CDN-backed edge endpoint.

Pulumi lets you define and update cloud infrastructure with popular programming languages. In this guide, that code creates the hosting resources, publishes your built site, and exports the DNS values you need when you are ready to delegate a domain.

The first deployment gives you a provider-managed URL you can open right away. Custom domains, DNS delegation, and CI/CD come after that first successful deploy.

What gets deployed

This blueprint follows the same basic flow:

  1. build the app with npm run build
  2. upload the generated files from dist
  3. put a CDN in front of the origin
  4. export a public URL you can open in the browser
  5. optionally create a managed DNS zone and the values you need for delegation later

Start with the downloaded example, get one successful deploy working, then swap in your real app and add your real domain workflow when you are ready.

Quickstart

If you want the fastest path to a first public URL, use the downloadable example and follow this sequence:

  1. Download the example zip at the top of the page and unzip it.
  2. Open a terminal in the extracted project root.
  3. Build the website:
cd website
npm install
npm run build
cd ..
  1. Install the Pulumi dependencies for the language you want to use:
npm install
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
go mod tidy
  1. For a first local test, you can keep using cloud credentials that already work in your shell. If you want a shared or repeatable setup, use the Pulumi ESC section below before continuing.
  2. Create the stack and deploy:
pulumi login
pulumi stack init dev
pulumi config set azure-native:location eastus
pulumi up
  1. When the update finishes, open the site URL:
pulumi stack output siteUrl

You should see the sample site, then you can come back to the later sections for DNS delegation and CI/CD.

On first deploy, the URL is the provider-managed site endpoint that Pulumi creates for you.

Prerequisites

Before you start, make sure you have:

  • an Azure subscription where you can create storage, CDN, and DNS resources
  • a Pulumi account and the Pulumi CLI installed. Pulumi lets you define and update cloud infrastructure with popular programming languages.
  • Node.js 20 or newer and npm so you can build the React app

For the Pulumi language you selected:

  • Python 3.11 or newer and a virtual environment tool
  • Go 1.23 or newer

Set up credentials with Pulumi ESC

Before you run pulumi up, set up Pulumi ESC so you can get short-lived cloud credentials through dynamic login credentials.

If you already have working Azure credentials in your shell and only want a quick local test, you can skip this section and come back later. ESC is the better long-term path for shared environments, Pulumi Deployments, and CI/CD.

Step 1: Create or update an ESC environment

Use imports if you want to layer this on top of a shared base environment.

imports:
  - <your-org>/base
values:
  azure:
    login:
      fn::open::azure-login:
        clientId: 00000000-0000-0000-0000-000000000000
        tenantId: 00000000-0000-0000-0000-000000000000
        subscriptionId: /subscriptions/00000000-0000-0000-0000-000000000000
        oidc: true
  pulumiConfig:
    azure-native:location: eastus

This example shows the pieces that matter for Azure:

  • the cloud login provider configured for OIDC
  • environment variables exported for local CLI use
  • pulumiConfig values passed into your Pulumi stack

Step 2: Attach the environment to your stack

In Pulumi.dev.yaml or your stack config file, add:

environment:
  - <your-org>/<your-environment>

That is what makes the ESC environment available to pulumi preview, pulumi up, and pulumi destroy.

Optional: Inspect the environment locally

Step 2 is all Pulumi needs to import the environment during pulumi preview, pulumi up, and pulumi destroy. If you want to sanity-check the resolved values from your shell, run:

esc open <your-org>/<your-environment>

You do not need to run this before pulumi up.

What you get in the download

The downloadable example zip includes:

  • website/ with a minimal React app you can build immediately
  • Pulumi.yaml
  • the Pulumi program, dependency files, and reusable website module for the language currently selected below
  • a README with a shorter quick start based on this page
  • index.ts as the Pulumi entrypoint
  • components/static-website.ts as the reusable website module
  • package.json and tsconfig.json for the root Pulumi project
  • __main__.py as the Pulumi entrypoint
  • components/static_website.py as the reusable website module
  • requirements.txt for the root Pulumi project
  • main.go as the Pulumi entrypoint
  • staticwebsite/site.go as the reusable website module
  • go.mod for the root Pulumi project

If you want to understand how the app itself is created, the next section walks through the smallest useful scaffold.

Build the app

The example already includes a small React app built with Vite. If you want to create the same kind of blueprint from scratch, use these steps.

Step 1: Create a React app

npm create vite@latest website -- --template react
cd website
npm install

Step 2: Replace src/App.jsx with a tiny app you can recognize after deployment

export default function App() {
    return (
        <main>
            <h1>Hello from React and Pulumi</h1>
            <p>Your frontend build is working.</p>
        </main>
    );
}

Step 3: Build the production files

npm run build

Vite writes the production files to website/dist.

Step 4: Optional local preview

npm run preview

For this Azure React setup, the hosting configuration is set up so deep links load index.html instead of a raw storage 404 page.

Deploy with Pulumi

Follow these steps in order from the project root.

Step 1: Build the website

cd website
npm install
npm run build
cd ..

This creates the files Pulumi will publish from website/dist.

Step 2: Install the root Pulumi dependencies for the language you want to use

The download card and the Pulumi code examples on this page follow the same language selection.

npm install
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
go mod tidy

Step 3: Create a Pulumi stack

If you already created the stack once, run pulumi stack select dev instead. For a fuller walkthrough, see the Pulumi getting started docs.

pulumi login
pulumi stack init dev
pulumi config set azure-native:location eastus

Step 4: Deploy

pulumi up

Approve the preview when Pulumi asks. On the first run, this creates the cloud resources and uploads your built site. Pulumi imports the ESC environment automatically through the environment: reference in your stack config, so you do not need to run esc open <your-org>/<your-environment> first.

Step 5: Open the URL from the Pulumi output

Pulumi prints the public URL at the end of the update. Open that URL in your browser to confirm the site is live.

Get the site URL from stack outputs

After the deployment finishes, you can fetch the public URL again at any time:

pulumi stack output siteUrl

You can also inspect the other edge and DNS-related outputs:

pulumi stack output edgeEndpoint
pulumi stack output dnsZoneNameServers --json
pulumi stack output customDomainHost
pulumi stack output customDomainRecordValue

On Azure, siteUrl points at the CDN endpoint hostname that Pulumi creates for you.

Plan your DNS handoff

If you want this blueprint to create a managed DNS zone for a domain you control, set these Pulumi config values before pulumi up:

pulumi config set domainName example.com
pulumi config set subdomain www

After deployment:

  1. run pulumi stack output dnsZoneNameServers --json
  2. delegate your domain at the registrar or parent DNS provider to those name servers (see the walkthrough below)
  3. keep using siteUrl while DNS propagates
  4. when you are ready to attach a custom domain at the edge, use customDomainHost, customDomainRecordType, and customDomainRecordValue as the values to carry into that follow-up step

Delegate your domain at your registrar

A domain registrar holds the authoritative NS records for your domain. Delegation tells the public DNS system to ask Azure DNS (the zone this stack created) for records under your domain instead of the registrar’s default nameservers. Until delegation is complete, the Azure DNS zone exists but the internet still resolves your domain at the registrar.

Run the same steps regardless of which registrar you use:

  1. Copy the four nameserver hostnames from pulumi stack output dnsZoneNameServers --json.
  2. Sign in to your registrar (for example GoDaddy, Namecheap, Cloudflare Registrar, Squarespace/Google Domains, or Route 53 Registrar) and open the nameserver settings for your domain. Registrars label this area differently, but it is usually under DNS, Nameservers, or Domain settings.
  3. Switch from the registrar’s default nameservers to custom nameservers and paste the four values Pulumi exported. Save.
  4. Wait for propagation. Most registrars publish the change in a few minutes. In rare cases allow up to 24 hours.
  5. Verify with dig NS example.com +short (or nslookup -type=NS example.com on Windows). The four hostnames in the output should match the ones you pasted.

After delegation on Azure

  • Pick the right record for the subdomain you configured. pulumi stack output customDomainRecordType returns CNAME for this setup, pointing at the Azure Front Door endpoint hostname.
  • Subdomain vs apex: a subdomain is any prefixed hostname such as www.example.com or blog.example.com. The apex is the bare domain with no prefix, such as example.com. The blueprint defaults to a subdomain, which is the safe default.
    • Subdomain (recommended): keep the exported record as-is.
    • Apex: easiest path is to keep www as the public hostname. If you must use the apex, create an Azure DNS alias record set (type A, target the Front Door endpoint resource).
  • Once delegation is live, follow the blueprint’s custom-domain follow-up to attach a certificate and bind the hostname to Azure Front Door. This blueprint intentionally stops before full custom-domain attachment and certificate validation so the first deployment stays small and deterministic.

Set up CI/CD with Pulumi Deployments

Pulumi Deployments is the simplest next step once the local workflow is working.

Use this blueprint flow:

  1. push the downloaded example to a Git repository
  2. create the stack in Pulumi Cloud and connect it to that repository
  3. set the stack’s build command to:
cd website
npm install
npm run build
cd ..
  1. keep the Pulumi working directory at the repository root so the deployment can see both website/ and the Pulumi project files
  2. configure the same cloud credentials and Pulumi config values in the deployment settings, including domainName and subdomain if you want Pulumi to manage the DNS zone

That gives you the same deployment path from pull requests and merges without having to hand-roll a separate CI script first.

Blueprint Pulumi program

Each download already includes the matching Pulumi entrypoint file and the reusable website module for that language. Use the language tabs to see the exact entrypoint for the version you want to run.

import * as pulumi from "@pulumi/pulumi";
import { existsSync, statSync } from "fs";
import { StaticWebsite } from "./components/static-website";

const buildDir = "website/dist";

if (!existsSync(buildDir) || !statSync(buildDir).isDirectory()) {
    throw new Error(`Build output not found at ${buildDir}. Run "cd website && npm run build" first.`);
}

const config = new pulumi.Config();

const website = new StaticWebsite("site", {
    buildDir,
    domainName: config.get("domainName") ?? undefined,
    subdomain: config.get("subdomain") ?? "www",
    errorDocument: "index.html",
    tags: {
        "solution-family": "static-website",
        cloud: "azure",
        framework: "react",
        language: "typescript",
    },
});

export const buildCommand = "npm run build";
export const publishDirectory = buildDir;
export const edgeEndpoint = website.edgeEndpoint;
export const siteUrl = website.siteUrl;
export const dnsZoneName = website.dnsZoneName;
export const dnsZoneNameServers = website.dnsZoneNameServers;
export const customDomainHost = website.customDomainHost;
export const customDomainRecordType = website.customDomainRecordType;
export const customDomainRecordValue = website.customDomainRecordValue;
import os

import pulumi

from components.static_website import StaticWebsite

build_dir = "website/dist"

if not os.path.isdir(build_dir):
    raise RuntimeError(
        f"Build output not found at {build_dir}. Run \"cd website && npm run build\" first."
    )

config = pulumi.Config()

website = StaticWebsite(
    "site",
    build_dir=build_dir,
    domain_name=config.get("domainName"),
    subdomain=config.get("subdomain") or "www",
    error_document="index.html",
    tags={
        "solution-family": "static-website",
        "cloud": "azure",
        "framework": "react",
        "language": "python",
    },
)

pulumi.export("buildCommand", "npm run build")
pulumi.export("publishDirectory", build_dir)
pulumi.export("edgeEndpoint", website.edge_endpoint)
pulumi.export("siteUrl", website.site_url)
pulumi.export("dnsZoneName", website.dns_zone_name)
pulumi.export("dnsZoneNameServers", website.dns_zone_name_servers)
pulumi.export("customDomainHost", website.custom_domain_host)
pulumi.export("customDomainRecordType", website.custom_domain_record_type)
pulumi.export("customDomainRecordValue", website.custom_domain_record_value)
package main

import (
	"fmt"
	"os"

	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"

	"static-website-azure-react/staticwebsite"
)

func main() {
	pulumi.Run(Program)
}

func Program(ctx *pulumi.Context) error {
	buildDir := "website/dist"
	if info, err := os.Stat(buildDir); err != nil || !info.IsDir() {
		return fmt.Errorf("build output not found at %s. Run \"cd website && npm run build\" first", buildDir)
	}

	cfg := config.New(ctx, "")
	domainName := cfg.Get("domainName")
	subdomain := cfg.Get("subdomain")
	if subdomain == "" {
		subdomain = "www"
	}

	website, err := staticwebsite.NewStaticWebsite(ctx, "site", &staticwebsite.StaticWebsiteArgs{
		BuildDir:      buildDir,
		DomainName:    domainName,
		Subdomain:     subdomain,
		ErrorDocument: "index.html",
		Tags: map[string]string{
			"solution-family": "static-website",
			"cloud":           "azure",
			"framework":       "react",
			"language":        "go",
		},
	})
	if err != nil {
		return err
	}

	ctx.Export("buildCommand", pulumi.String("npm run build"))
	ctx.Export("publishDirectory", pulumi.String(buildDir))
	ctx.Export("edgeEndpoint", website.EdgeEndpoint)
	ctx.Export("siteUrl", website.SiteUrl)
	ctx.Export("dnsZoneName", website.DnsZoneName)
	ctx.Export("dnsZoneNameServers", website.DnsZoneNameServers)
	ctx.Export("customDomainHost", website.CustomDomainHost)
	ctx.Export("customDomainRecordType", website.CustomDomainRecordType)
	ctx.Export("customDomainRecordValue", website.CustomDomainRecordValue)
	return nil
}

Reusable components

The entrypoint stays small because the website wiring lives in a reusable module. The downloadable blueprint ships the same component shown below for each language.

import * as azure from "@pulumi/azure-native";
import * as pulumi from "@pulumi/pulumi";
import * as synced from "@pulumi/synced-folder";

export interface StaticWebsiteArgs {
    buildDir: string;
    domainName?: string;
    subdomain?: string;
    errorDocument: string;
    tags?: Record<string, string>;
}

export class StaticWebsite {
    public readonly siteUrl: pulumi.Output<string>;
    public readonly edgeEndpoint: pulumi.Output<string>;
    public readonly dnsZoneName: pulumi.Output<string | undefined>;
    public readonly dnsZoneNameServers: pulumi.Output<string[]>;
    public readonly customDomainHost: pulumi.Output<string | undefined>;
    public readonly customDomainRecordType: pulumi.Output<string | undefined>;
    public readonly customDomainRecordValue: pulumi.Output<string | undefined>;

    constructor(name: string, args: StaticWebsiteArgs) {
        const tags = args.tags ?? {};
        const location = new pulumi.Config("azure-native").require("location");

        const resourceGroup = new azure.resources.ResourceGroup(`${name}-rg`, {
            location,
            tags,
        });

        const storageAccount = new azure.storage.StorageAccount(`${name}storage`, {
            accountName: pulumi.interpolate`${pulumi.getProject().replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8)}${pulumi.getStack().replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8)}`,
            location,
            resourceGroupName: resourceGroup.name,
            sku: { name: azure.storage.SkuName.Standard_LRS },
            kind: azure.storage.Kind.StorageV2,
            tags,
        });

        const staticWebsite = new azure.storage.StorageAccountStaticWebsite(`${name}-website`, {
            accountName: storageAccount.name,
            resourceGroupName: resourceGroup.name,
            indexDocument: "index.html",
            error404Document: args.errorDocument,
        });

        const edgeProfile = new azure.cdn.Profile(`${name}-edge`, {
            resourceGroupName: resourceGroup.name,
            location: "global",
            profileName: `${name}-profile`,
            sku: { name: azure.cdn.SkuName.Standard_AzureFrontDoor },
            tags,
        });

        const webOriginHost = storageAccount.primaryEndpoints.web.apply((endpoint) => new URL(endpoint).host);

        const edgeEndpoint = new azure.cdn.AFDEndpoint(`${name}-endpoint`, {
            endpointName: `${name}-endpoint`,
            profileName: edgeProfile.name,
            resourceGroupName: resourceGroup.name,
            location: "global",
            enabledState: azure.cdn.EnabledState.Enabled,
            tags,
        });

        const originGroup = new azure.cdn.AFDOriginGroup(`${name}-origin-group`, {
            originGroupName: "storage-static",
            profileName: edgeProfile.name,
            resourceGroupName: resourceGroup.name,
            loadBalancingSettings: {
                sampleSize: 4,
                successfulSamplesRequired: 3,
                additionalLatencyInMilliseconds: 50,
            },
            healthProbeSettings: {
                probePath: "/",
                probeRequestType: azure.cdn.HealthProbeRequestType.HEAD,
                probeProtocol: azure.cdn.ProbeProtocol.Https,
                probeIntervalInSeconds: 120,
            },
        });

        const origin = new azure.cdn.AFDOrigin(`${name}-origin`, {
            originName: "storage-static",
            originGroupName: originGroup.name,
            profileName: edgeProfile.name,
            resourceGroupName: resourceGroup.name,
            hostName: webOriginHost,
            originHostHeader: webOriginHost,
            httpsPort: 443,
            enabledState: azure.cdn.EnabledState.Enabled,
        }, { dependsOn: [staticWebsite] });

        new azure.cdn.Route(`${name}-route`, {
            routeName: "default",
            endpointName: edgeEndpoint.name,
            profileName: edgeProfile.name,
            resourceGroupName: resourceGroup.name,
            originGroup: { id: originGroup.id },
            supportedProtocols: [
                azure.cdn.AFDEndpointProtocols.Http,
                azure.cdn.AFDEndpointProtocols.Https,
            ],
            patternsToMatch: ["/*"],
            forwardingProtocol: azure.cdn.ForwardingProtocol.HttpsOnly,
            httpsRedirect: azure.cdn.HttpsRedirect.Enabled,
            linkToDefaultDomain: azure.cdn.LinkToDefaultDomain.Enabled,
            enabledState: azure.cdn.EnabledState.Enabled,
        }, { dependsOn: [origin] });

        new synced.AzureBlobFolder(`${name}-files`, {
            resourceGroupName: resourceGroup.name,
            storageAccountName: storageAccount.name,
            containerName: staticWebsite.containerName,
            path: args.buildDir,
        });

        const dnsZone = args.domainName
            ? new azure.dns.Zone(`${name}-dns`, {
                  resourceGroupName: resourceGroup.name,
                  zoneName: args.domainName,
                  tags,
              })
            : undefined;

        const customDomainHost = args.domainName
            ? `${args.subdomain ?? "www"}.${args.domainName}`
            : undefined;

        this.edgeEndpoint = edgeEndpoint.hostName;
        this.siteUrl = pulumi.interpolate`https://${edgeEndpoint.hostName}`;
        this.dnsZoneName = pulumi.output(args.domainName);
        this.dnsZoneNameServers = dnsZone ? dnsZone.nameServers : pulumi.output([]);
        this.customDomainHost = pulumi.output(customDomainHost);
        this.customDomainRecordType = pulumi.output(
            customDomainHost ? "CNAME" : undefined,
        );
        this.customDomainRecordValue = customDomainHost
            ? edgeEndpoint.hostName.apply((value) => value)
            : pulumi.output(undefined);
    }
}
from __future__ import annotations

from dataclasses import dataclass

import pulumi
import pulumi_azure_native as azure_native
import pulumi_synced_folder as synced_folder


@dataclass
class StaticWebsite:
    edge_endpoint: pulumi.Output[str]
    site_url: pulumi.Output[str]
    dns_zone_name: pulumi.Output[str | None]
    dns_zone_name_servers: pulumi.Output[list[str]]
    custom_domain_host: pulumi.Output[str | None]
    custom_domain_record_type: pulumi.Output[str | None]
    custom_domain_record_value: pulumi.Output[str | None]

    def __init__(
        self,
        name: str,
        *,
        build_dir: str,
        domain_name: str | None,
        subdomain: str,
        error_document: str,
        tags: dict[str, str] | None = None,
    ) -> None:
        tags = tags or {}
        location = pulumi.Config("azure-native").require("location")

        resource_group = azure_native.resources.ResourceGroup(
            f"{name}-rg",
            location=location,
            tags=tags,
        )

        storage_account = azure_native.storage.StorageAccount(
            f"{name}storage",
            account_name=(pulumi.get_project().replace("-", "")[:8] + pulumi.get_stack().replace("-", "")[:8]).lower(),
            location=location,
            resource_group_name=resource_group.name,
            sku=azure_native.storage.SkuArgs(name=azure_native.storage.SkuName.STANDARD_LRS),
            kind=azure_native.storage.Kind.STORAGE_V2,
            tags=tags,
        )

        static_website = azure_native.storage.StorageAccountStaticWebsite(
            f"{name}-website",
            account_name=storage_account.name,
            resource_group_name=resource_group.name,
            index_document="index.html",
            error404_document=error_document,
        )

        edge_profile = azure_native.cdn.Profile(
            f"{name}-edge",
            profile_name=f"{name}-profile",
            resource_group_name=resource_group.name,
            location="global",
            sku=azure_native.cdn.SkuArgs(name=azure_native.cdn.SkuName.STANDARD_AZURE_FRONT_DOOR),
            tags=tags,
        )

        web_origin_host = storage_account.primary_endpoints.web.apply(
            lambda endpoint: endpoint.replace("https://", "").rstrip("/")
        )

        edge_endpoint = azure_native.cdn.AFDEndpoint(
            f"{name}-endpoint",
            endpoint_name=f"{name}-endpoint",
            profile_name=edge_profile.name,
            resource_group_name=resource_group.name,
            location="global",
            enabled_state=azure_native.cdn.EnabledState.ENABLED,
            tags=tags,
        )

        origin_group = azure_native.cdn.AFDOriginGroup(
            f"{name}-origin-group",
            origin_group_name="storage-static",
            profile_name=edge_profile.name,
            resource_group_name=resource_group.name,
            load_balancing_settings=azure_native.cdn.LoadBalancingSettingsParametersArgs(
                sample_size=4,
                successful_samples_required=3,
                additional_latency_in_milliseconds=50,
            ),
            health_probe_settings=azure_native.cdn.HealthProbeParametersArgs(
                probe_path="/",
                probe_request_type=azure_native.cdn.HealthProbeRequestType.HEAD,
                probe_protocol=azure_native.cdn.ProbeProtocol.HTTPS,
                probe_interval_in_seconds=120,
            ),
        )

        origin = azure_native.cdn.AFDOrigin(
            f"{name}-origin",
            origin_name="storage-static",
            origin_group_name=origin_group.name,
            profile_name=edge_profile.name,
            resource_group_name=resource_group.name,
            host_name=web_origin_host,
            origin_host_header=web_origin_host,
            https_port=443,
            enabled_state=azure_native.cdn.EnabledState.ENABLED,
            opts=pulumi.ResourceOptions(depends_on=[static_website]),
        )

        azure_native.cdn.Route(
            f"{name}-route",
            route_name="default",
            endpoint_name=edge_endpoint.name,
            profile_name=edge_profile.name,
            resource_group_name=resource_group.name,
            origin_group=azure_native.cdn.ResourceReferenceArgs(id=origin_group.id),
            supported_protocols=[
                azure_native.cdn.AFDEndpointProtocols.HTTP,
                azure_native.cdn.AFDEndpointProtocols.HTTPS,
            ],
            patterns_to_match=["/*"],
            forwarding_protocol=azure_native.cdn.ForwardingProtocol.HTTPS_ONLY,
            https_redirect=azure_native.cdn.HttpsRedirect.ENABLED,
            link_to_default_domain=azure_native.cdn.LinkToDefaultDomain.ENABLED,
            enabled_state=azure_native.cdn.EnabledState.ENABLED,
            opts=pulumi.ResourceOptions(depends_on=[origin]),
        )

        synced_folder.AzureBlobFolder(
            f"{name}-files",
            resource_group_name=resource_group.name,
            storage_account_name=storage_account.name,
            container_name=static_website.container_name,
            path=build_dir,
        )

        dns_zone = None
        if domain_name:
            dns_zone = azure_native.dns.Zone(
                f"{name}-dns",
                resource_group_name=resource_group.name,
                zone_name=domain_name,
                tags=tags,
            )

        custom_domain_host = f"{subdomain}.{domain_name}" if domain_name else None

        self.edge_endpoint = edge_endpoint.host_name
        self.site_url = edge_endpoint.host_name.apply(lambda host: f"https://{host}")
        self.dns_zone_name = pulumi.Output.from_input(domain_name)
        self.dns_zone_name_servers = (
            dns_zone.name_servers if dns_zone else pulumi.Output.from_input([])
        )
        self.custom_domain_host = pulumi.Output.from_input(custom_domain_host)
        self.custom_domain_record_type = pulumi.Output.from_input(
            "CNAME" if custom_domain_host else None
        )
        self.custom_domain_record_value = (
            edge_endpoint.host_name if custom_domain_host else pulumi.Output.from_input(None)
        )
package staticwebsite

import (
	"fmt"
	"regexp"
	"strings"

	cdn "github.com/pulumi/pulumi-azure-native-sdk/cdn/v3"
	dns "github.com/pulumi/pulumi-azure-native-sdk/dns/v3"
	resources "github.com/pulumi/pulumi-azure-native-sdk/resources/v3"
	storage "github.com/pulumi/pulumi-azure-native-sdk/storage/v3"
	syncedfolder "github.com/pulumi/pulumi-synced-folder/sdk/go/synced-folder"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)

type StaticWebsiteArgs struct {
	BuildDir      string
	DomainName    string
	Subdomain     string
	ErrorDocument string
	Tags          map[string]string
}

type StaticWebsite struct {
	EdgeEndpoint            pulumi.StringOutput
	SiteUrl                 pulumi.StringOutput
	DnsZoneName             pulumi.StringPtrOutput
	DnsZoneNameServers      pulumi.StringArrayOutput
	CustomDomainHost        pulumi.StringPtrOutput
	CustomDomainRecordType  pulumi.StringPtrOutput
	CustomDomainRecordValue pulumi.StringPtrOutput
}

func sanitizeStorageAccountName(value string) string {
	matcher := regexp.MustCompile("[^a-z0-9]")
	cleaned := matcher.ReplaceAllString(strings.ToLower(value), "")
	if len(cleaned) < 18 {
		cleaned += strings.Repeat("0", 18-len(cleaned))
	}
	return cleaned[:18]
}

func NewStaticWebsite(ctx *pulumi.Context, name string, args *StaticWebsiteArgs) (*StaticWebsite, error) {
	location := config.New(ctx, "azure-native").Require("location")
	resourceGroup, err := resources.NewResourceGroup(ctx, fmt.Sprintf("%s-rg", name), &resources.ResourceGroupArgs{
		Location: pulumi.String(location),
		Tags:     pulumi.ToStringMap(args.Tags),
	})
	if err != nil {
		return nil, err
	}

	accountName := sanitizeStorageAccountName(ctx.Project() + ctx.Stack())
	storageAccount, err := storage.NewStorageAccount(ctx, fmt.Sprintf("%sstorage", name), &storage.StorageAccountArgs{
		AccountName:       pulumi.String(accountName),
		Location:          pulumi.String(location),
		ResourceGroupName: resourceGroup.Name,
		Sku: &storage.SkuArgs{
			Name: pulumi.String(storage.SkuName_Standard_LRS),
		},
		Kind: pulumi.String(storage.KindStorageV2),
		Tags: pulumi.ToStringMap(args.Tags),
	})
	if err != nil {
		return nil, err
	}

	staticWebsite, err := storage.NewStorageAccountStaticWebsite(ctx, fmt.Sprintf("%s-website", name), &storage.StorageAccountStaticWebsiteArgs{
		AccountName:       storageAccount.Name,
		ResourceGroupName: resourceGroup.Name,
		IndexDocument:     pulumi.String("index.html"),
		Error404Document:  pulumi.String(args.ErrorDocument),
	})
	if err != nil {
		return nil, err
	}

	edgeProfile, err := cdn.NewProfile(ctx, fmt.Sprintf("%s-edge", name), &cdn.ProfileArgs{
		ProfileName:       pulumi.String(fmt.Sprintf("%s-profile", name)),
		ResourceGroupName: resourceGroup.Name,
		Location:          pulumi.String("global"),
		Sku: &cdn.SkuArgs{
			Name: pulumi.String(cdn.SkuName_Standard_AzureFrontDoor),
		},
		Tags: pulumi.ToStringMap(args.Tags),
	})
	if err != nil {
		return nil, err
	}

	webOriginHost := storageAccount.PrimaryEndpoints.ApplyT(func(endpoints storage.EndpointsResponse) string {
		return strings.TrimSuffix(strings.TrimPrefix(endpoints.Web, "https://"), "/")
	}).(pulumi.StringOutput)

	edgeEndpoint, err := cdn.NewAFDEndpoint(ctx, fmt.Sprintf("%s-endpoint", name), &cdn.AFDEndpointArgs{
		EndpointName:      pulumi.String(fmt.Sprintf("%s-endpoint", name)),
		ProfileName:       edgeProfile.Name,
		ResourceGroupName: resourceGroup.Name,
		Location:          pulumi.String("global"),
		EnabledState:      pulumi.String(cdn.EnabledStateEnabled),
		Tags:              pulumi.ToStringMap(args.Tags),
	})
	if err != nil {
		return nil, err
	}

	originGroup, err := cdn.NewAFDOriginGroup(ctx, fmt.Sprintf("%s-origin-group", name), &cdn.AFDOriginGroupArgs{
		OriginGroupName:   pulumi.String("storage-static"),
		ProfileName:       edgeProfile.Name,
		ResourceGroupName: resourceGroup.Name,
		LoadBalancingSettings: &cdn.LoadBalancingSettingsParametersArgs{
			SampleSize:                     pulumi.Int(4),
			SuccessfulSamplesRequired:      pulumi.Int(3),
			AdditionalLatencyInMilliseconds: pulumi.Int(50),
		},
		HealthProbeSettings: &cdn.HealthProbeParametersArgs{
			ProbePath:              pulumi.String("/"),
			ProbeRequestType:       cdn.HealthProbeRequestTypeHEAD,
			ProbeProtocol:          cdn.ProbeProtocolHttps,
			ProbeIntervalInSeconds: pulumi.Int(120),
		},
	})
	if err != nil {
		return nil, err
	}

	origin, err := cdn.NewAFDOrigin(ctx, fmt.Sprintf("%s-origin", name), &cdn.AFDOriginArgs{
		OriginName:        pulumi.String("storage-static"),
		OriginGroupName:   originGroup.Name,
		ProfileName:       edgeProfile.Name,
		ResourceGroupName: resourceGroup.Name,
		HostName:          webOriginHost,
		OriginHostHeader:  webOriginHost,
		HttpsPort:         pulumi.Int(443),
		EnabledState:      pulumi.String(cdn.EnabledStateEnabled),
	}, pulumi.DependsOn([]pulumi.Resource{staticWebsite}))
	if err != nil {
		return nil, err
	}

	_, err = cdn.NewRoute(ctx, fmt.Sprintf("%s-route", name), &cdn.RouteArgs{
		RouteName:         pulumi.String("default"),
		EndpointName:      edgeEndpoint.Name,
		ProfileName:       edgeProfile.Name,
		ResourceGroupName: resourceGroup.Name,
		OriginGroup: &cdn.ResourceReferenceArgs{
			Id: originGroup.ID().ToStringOutput(),
		},
		SupportedProtocols: pulumi.StringArray{
			pulumi.String(cdn.AFDEndpointProtocolsHttp),
			pulumi.String(cdn.AFDEndpointProtocolsHttps),
		},
		PatternsToMatch:     pulumi.StringArray{pulumi.String("/*")},
		ForwardingProtocol:  pulumi.String(cdn.ForwardingProtocolHttpsOnly),
		HttpsRedirect:       pulumi.String(cdn.HttpsRedirectEnabled),
		LinkToDefaultDomain: pulumi.String(cdn.LinkToDefaultDomainEnabled),
		EnabledState:        pulumi.String(cdn.EnabledStateEnabled),
	}, pulumi.DependsOn([]pulumi.Resource{origin}))
	if err != nil {
		return nil, err
	}

	_, err = syncedfolder.NewAzureBlobFolder(ctx, fmt.Sprintf("%s-files", name), &syncedfolder.AzureBlobFolderArgs{
		ResourceGroupName:  resourceGroup.Name,
		StorageAccountName: storageAccount.Name,
		ContainerName:      staticWebsite.ContainerName,
		Path:               pulumi.String(args.BuildDir),
	})
	if err != nil {
		return nil, err
	}

	var dnsZone *dns.Zone
	if args.DomainName != "" {
		dnsZone, err = dns.NewZone(ctx, fmt.Sprintf("%s-dns", name), &dns.ZoneArgs{
			ResourceGroupName: resourceGroup.Name,
			ZoneName:          pulumi.String(args.DomainName),
			Tags:              pulumi.ToStringMap(args.Tags),
		})
		if err != nil {
			return nil, err
		}
	}

	var nilString *string
	result := &StaticWebsite{
		EdgeEndpoint: edgeEndpoint.HostName,
		SiteUrl: edgeEndpoint.HostName.ApplyT(func(host string) string {
			return fmt.Sprintf("https://%s", host)
		}).(pulumi.StringOutput),
		DnsZoneName:             pulumi.ToOutput(nilString).(pulumi.StringPtrOutput),
		DnsZoneNameServers:      pulumi.ToOutput([]string{}).(pulumi.StringArrayOutput),
		CustomDomainHost:        pulumi.ToOutput(nilString).(pulumi.StringPtrOutput),
		CustomDomainRecordType:  pulumi.ToOutput(nilString).(pulumi.StringPtrOutput),
		CustomDomainRecordValue: pulumi.ToOutput(nilString).(pulumi.StringPtrOutput),
	}

	if args.DomainName != "" {
		customDomainHost := fmt.Sprintf("%s.%s", args.Subdomain, args.DomainName)
		recordType := "CNAME"
		result.DnsZoneName = pulumi.ToOutput(&args.DomainName).(pulumi.StringPtrOutput)
		result.DnsZoneNameServers = dnsZone.NameServers
		result.CustomDomainHost = pulumi.ToOutput(&customDomainHost).(pulumi.StringPtrOutput)
		result.CustomDomainRecordType = pulumi.ToOutput(&recordType).(pulumi.StringPtrOutput)
		result.CustomDomainRecordValue = edgeEndpoint.HostName.ApplyT(func(value string) *string {
			return &value
		}).(pulumi.StringPtrOutput)
	}

	return result, nil
}

Frequently asked questions

What do I need before I start?
You need a Pulumi account, the Pulumi CLI, a cloud account in AWS, Azure, or GCP, and the framework tools for the setup you picked. The language section on the page calls out the extra runtime you need for your language choice (TypeScript, Python, or Go).
How do I deploy changes after the first release?
Edit the files in website/, rebuild the app, and run pulumi up again from the same stack. Pulumi updates the hosting resources as needed and republishes the built site output.
Does this blueprint create DNS for me?
The code-based downloads create a managed DNS zone when you set domainName, and every setup exports the nameservers you need to delegate at your registrar. The DNS section walks through copying those values from pulumi stack output, switching to custom nameservers at the registrar, and verifying delegation with dig. The blueprint stops short of full custom-domain attachment and certificate validation so the first deployment stays small and predictable.
How do I automate this in CI/CD?
Use Pulumi Deployments to run the same stack update from your Git repository. The guide includes a blueprint section that explains the repo, stack, build command, and secrets you need to wire in.
How do I tear everything down?
Run pulumi destroy from the same project and stack, then remove the stack with pulumi stack rm if you no longer need it. If you delegated DNS to a hosted zone created by this stack, remove that delegation at your registrar or parent DNS provider too.
Will this cost money?
Yes. Blueprint costs are typically small, but storage, CDN requests, bandwidth, load balancing, DNS zones, and any later custom-domain or certificate resources can all create charges in your cloud account.