Hosting a Static Website on Azure with Pulumi

Posted on

Static websites are back in the mainstream these days. Website generators like Jekyll, Hugo, or Gatsby, make it fairly easy to combine templates and markdown pages to produce static HTML files. Static assets are the simplest thing to serve and cache, so the whole setup ends up being fast and cost-efficient.

Many platforms offer services to host such static websites. This post explains the steps to create the infrastructure to do so on Microsoft Azure.

Setting up the infrastructure to serve a static website doesn’t sound like it would be all that difficult, but when you consider HTTPS certificates, content distribution networks, and attaching it to a custom domain, integrating all the components can be quite daunting.

Fortunately, this is a task where Pulumi shines. Pulumi’s code-centric approach not only makes configuring cloud resources easier to do and maintain, but it also eliminates the pain of integrating multiple services.

Overview

Our goal is to create a static website with a custom domain – I’ll use an imaginary demo.pulumi.com for this article. In 2019, my website has to support HTTPS, so we need to create a custom TLS certificate too.

The final solution consists of several Azure services:

  • Static files will be stored in a Blob Container inside a Storage Account
  • The Storage Account will have Static Website feature enabled to have some basic URL rewrite rules
  • We’ll put an Azure CDN Endpoint in front of the container to support the custom domain over TLS
  • Azure CDN will self-manage the TLS certificate
  • Our custom DNS provider will have the rule to point to the CDN endpoint (that’s a manual step)

The diagram below outlines the interaction of these components:

Static website running on Azure and defined in Pulumi

Let’s break down how to configure each component using Pulumi.

Resource Group

Let’s start a new Pulumi program, import the Pulumi packages, and define a new resource group:

import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure";

const resourceGroup = new azure.core.ResourceGroup("demo-rg", {
    location: azure.WestEurope,
});

Storage Account

The Storage Account will contain our static website’s assets:

const storageAccount = new azure.storage.Account("demopulumi", {
    resourceGroupName: resourceGroup.name,
    accountReplicationType: "LRS",
    accountTier: "Standard",
    accountKind: "StorageV2",
});

The only trick here is to make sure that the account kind is V2; otherwise, it won’t support the static website feature.

Static Website Hosting in Azure Storage

Any storage container could be exposed as a web endpoint. However, that’s not flexible enough. The URL would always include the container name and the exact file name, so the user would have to ask for https://demo.pulumi.com/containername/index.html instead of simply https://demo.pulumi.com/.

Enabling Static Website Hosting in Azure Storage improves this experience. A dedicated container $web gets automatically created, which also has special treatment for index and 404 documents.

The bad news is that Static Website Hosting is not a part of Azure Resource Manager API, and therefore, it’s not available out-of-the-box in ARM templates, Terraform, or Pulumi. We can enable this feature with Azure CLI, so the solution is to create a dynamic Pulumi resource which enables Pulumi experience while delegating the work to the CLI. You can find the full source code for the dynamic resource in this example, but the usage is quite trivial:

const staticWebsite = new StorageStaticWebsite("demopulumi-static",
    { accountName: storageAccount.name },
    { parent: storageAccount });

// Web endpoint to the website
export const staticEndpoint = staticWebsite.endpoint;

The last line exports the static website endpoint. It will look something like https://demopulumi01234abc.z6.web.core.windows.net/—no custom domain yet, but already functional—once we deploy some static files in there.

Static Files

Now, it’s time to upload the static files to the $web Blob Container.

For this demo, I’ve created a folder wwwroot with two files in it: index.html and 404.html. I can upload those files with the following Pulumi snippet:

["index.html", "404.html"].map(name =>
    new azure.storage.Blob(name, {
        name,
        resourceGroupName: resourceGroup.name,
        storageAccountName: storageAccount.name,
        storageContainerName: staticWebsite.webContainerName,
        type: "block",
        source: `./wwwroot/${name}`,
        contentType: "text/html",
    })
);

In practice, your static website might contain hundreds or thousands of files. At that point, you might want to split the file upload operation from Pulumi and do it as a separate step in your CI/CD pipeline.

Azure CDN

To make the static website files available over our custom domain and HTTPS, we need to create an Azure CDN Endpoint and to point it to the storage account:

const cdn = new azure.cdn.Profile("demo-cdn", {
    resourceGroupName: resourceGroup.name,
    sku: "Standard_Microsoft",
});

const endpoint = new azure.cdn.Endpoint("demo-cdn-ep", {
    name: "demopulumi",
    resourceGroupName: resourceGroup.name,
    profileName: cdn.name,
    isHttpAllowed: true,
    isHttpsAllowed: true,
    originHostHeader: staticWebsite.hostName,
    origins: [{
        name: "blobstorage",
        hostName: staticWebsite.hostName,
    }],
});

// CDN endpoint to the website. Allow it some time after the deployment to get ready.
export const cdnEndpoint = pulumi.interpolate`https://${endpoint.hostName}/`;

I specified an explicit name for the CDN Endpoint: demopulumi. This name has to be globally unique because it is a part of the endpoint URL https://demopulumi.azureedge.net. Please pick a custom name before running the program.

I pointed the CDN origin to the static website name with staticWebsite.hostName. As usual, it’s easy to link resources in Pulumi code!

You might need to wait a few minutes before your content is visible as the CDN configuration is not immediately executed. I’ve set isHttpAllowed to true because HTTP is available sooner than HTTPS; feel free to switch it off for your production configuration.

Configure a Domain DNS Rule

You’ve probably registered your domain with some third-party provider. Follow the instructions of your provider to configure a CNAME rule for the website’s DNS. For a custom domain demo.pulumi.com, the CNAME entry demo would be linked to the endpoint demopulumi.azureedge.net.

CNAME entries don’t support “naked” domains like pulumi.com. If you want to set up a top-level domain to be served from the static Azure website, you’d have to use an Alias DNS record, which isn’t supported by some DNS providers. Please check with your provider for available options.

The following step assumes that a CNAME record is configured; otherwise, it would fail with a validation error.

Custom Domain and TLS

The final step is to point our custom domain to the CDN endpoint and provision a TLS certificate to enable HTTPS support. Once again, these operations are not parts of the ARM API surface, so another dynamic resource was created to support them.

The usage is quite straightforward, just make sure to use your own domain in the following snippet:

const customDomain = new CDNCustomDomainResource("cdn-custom-domain", {
    resourceGroupName: resourceGroup.name,
    // Ensure that there is a CNAME record for demo pointing.pulumi.com to demopulumi.azureedge.net.
    // You would do that in your domain registrar's portal.
    customDomainHostName: "demo.pulumi.com",
    profileName: cdn.name,
    endpointName: endpoint.name,
    // This will enable HTTPS through Azure's one-click automated certificate deployment.
    // The certificate is fully managed by Azure from provisioning to automatic renewal
    // at no additional cost to you.
    httpsEnabled: true,
}, { parent: endpoint });

Bring It Live!

And we are done! Run pulumi up and make sure that all resources get created successfully.

Start testing with staticEndpoint—it’s the first one to become available. cdnEndpoint might return some 404’s at first, be patient. Then, try your custom domain with HTTP. Finally, the TLS certificate takes quite a while to be registered, so come back in an hour or so to test HTTPS.

While a “static website” may sound simple, we went through a rather complicated process to wire all the components together. Some Azure services and features are more straightforward to automate than others, but in the end, we combined all of them into one cohesive Pulumi program to host a static website, served over HTTPS and from a worldwide CDN. That’s the power of a general-purpose programming language applied to the task of sophisticated infrastructure automation. Once the reusable components are in place, reliable and reproducible deployments become a reality.