Static Site With Azure Cdn and Cloudflare

In my last post, I described how to create a Hugo website and what I did to migrate from my Miniblog platform, along with some details on how to create the build & deployment pipeline.

I started by deploying my Hugo websites to a regular Azure App Service. This is a full-blown web application platform. It’s a bit too overpowered for hosting a simple, static, website. As I mentioned in the earlier post, it makes a lot more sense to host static websites on an Azure Storage Account with the Static website hosting. The main reason I postponed this is that I had some issues creating my routing rules.

Moving to static site hosting ASAP

After having migrated to Hugo & the App Service hosting model, I quickly noticed moving to the static site hosting option was quite important. Every time my deployment pipeline was deploying the files to the App Service, the site became unavailable.

page got 404

The pages returned a 404 and when navigating to the root site, the site was just empty. empty site

This is bad, really bad. Of course, I can solve this by deploying the site to a Staging slot and swap with Production when ready. This is quite doable, but not a path I wanted to pursue.

Creating the static site hosting environment

First things first.

To host your static site on an Azure Storage Account, you need to create one. I already had my ARM template in source control and the continuous integration and -deployment pipeline set up. Creating a new storage account is rather easy.

For this specific site, the complete azuredeploy.json file looks like this:

{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
    },
    "variables": {
        "storageAccountName": "thenameofmystorageaccount"
    },
    "resources": [
        {
            "type": "Microsoft.Storage/storageAccounts",
            "apiVersion": "2019-04-01",
            "name": "[variables('storageAccountName')]",
            "location": "[resourceGroup().location]",
            "sku": {
                "name": "Standard_LRS",
                "tier": "Standard"
            },
            "kind": "StorageV2"
        }
    ]
}

You can deploy this ARM template and you’ll see the storage account being created. Now we still need to enable the static website hosting option. To configure this feature, I created a small PowerShell script and run this in the deployment pipeline.

$storageAccount = "thenameofmystorageaccount"
# Enabling the static site hosting option.
az storage blob service-properties update --account-name $storageAccount --static-website --index-document index.html --404-document 404.html

# Setting a pipeline variable of the primary web endpoint for this specific storage account.
$storageAccountProperties = az storage account show -n $storageAccount | ConvertFrom-Json
Write-Host "##vso[task.setvariable variable=storageAccountWebEndpoint]$($storageAccountProperties.primaryEndpoints.web)"

This script does 2 things.

The most important step is enabling the static website hosting option on your storage account. This will configure everything in your storage account and create a $web container in which you should place your static website files (html, css, js).

The second step over here is writing a pipeline variable of the web endpoint for the website. The primary web endpoint looks something like this https://[thenameofmystorageaccount].[zone].web.core.windows.net/. The first part of this endpoint is quite easy to guess. The second part, zone, a bit less. From what I have experienced myself and by talking to others, the zone in West Europe seems to have the value z6 all the time. However, I also saw some blogposts with other numbers (probably in other regions).

So, if you’re deploying to a specific region, you can assume what the zone will be. I haven’t been able to find anything on this in the documentation. To be sure, query the details of your storage account by using PowerShell (or some equivalent).

Adding Azure CDN to the story

Having a static website in a storage account is awesome because it’s very cheap and fast! Still, it’s only deployed to a single region. You probably want your site to be fast in the region West Europe, West- & East US, all of Australia, Asia, Africa, etc.

An easy way to facilitate this is by using a CDN. You can use Azure CDN for this. This also needs to be deployed via ARM templates.

Creating an Azure CDN profile is easy. It’s only 5 or 6 lines of JSON. But, you also need to deploy an endpoint & custom domains to it to make everything work.

The template I ended up with looks like the following.

{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "webEndpoint":{
            "type": "string"
        }
    },
    "variables": {
        "cdnProfileName": "my-cdn-profile-name",
        "cdnEndpointName": "my-endpoint-name",
        "webEndpointHostname": "[replace(substring(parameters('webEndpoint'), 7), '/', '')]",
        "originsName": "[replace(variables('webEndpointHostname'), '.', '-')]",
        "customdomain": {
            "rootDomain": {
                "domain": "jan-v.nl",
                "name": "janv-nl"
            },
            "wwwDomain": {
                "domain": "www.jan-v.nl",
                "name": "www-janv-nl"
            }
        }
    },
    "resources": [
        {
            "name": "[variables('cdnProfileName')]",
            "type": "Microsoft.Cdn/profiles",
            "apiVersion": "2019-04-15",
            "location": "Global",
            "sku": {
                "name": "Standard_Microsoft"
            },
            "properties": {},
            "resources": [
                {
                    "name": "[variables('cdnEndpointName')]",
                    "type": "endpoints",
                    "apiVersion": "2019-04-15",
                    "location": "Global",
                    "dependsOn": [
                        "[resourceId('Microsoft.Cdn/profiles', variables('cdnProfileName'))]"
                    ],
                    "properties":{
                        "originHostHeader": "[variables('webEndpointHostname')]",
                        "isHttpAllowed": false,
                        "isHttpsAllowed": true,
                        "queryStringCachingBehavior": "IgnoreQueryString",
                        "origins": [
                            {
                                "name": "[variables('originsName')]",
                                "properties": {
                                    "hostName": "[variables('webEndpointHostname')]"
                                }
                            }
                        ],
                        "contentTypesToCompress": [
                            "application/javascript",
                            "application/json",
                            "application/truetype",
                            "application/ttf",
                            "application/xhtml+xml",
                            "application/xml",
                            "application/xml+rss",
                            "text/css",
                            "text/html",
                            "text/javascript",
                            "text/js",
                            "text/plain",
                            "text/xml"
                        ],
                        "isCompressionEnabled": true
                    },
                    "resources": [
                        {
                            "type": "customdomains",
                            "apiVersion": "2016-04-02",
                            "name": "[concat(variables('cdnProfileName'), variables('cdnEndpointName'), variables('customdomain').rootDomain.name)]",
                            "dependsOn": [
                                "[resourceId('Microsoft.Cdn/profiles/endpoints', variables('cdnProfileName'), variables('cdnEndpointName'))]"
                            ],
                            "properties": {
                                "hostName": "[variables('customdomain').rootDomain.domain]"
                            }
                        },
                        {
                            "type": "customdomains",
                            "apiVersion": "2016-04-02",
                            "name": "[concat(variables('cdnProfileName'), variables('cdnEndpointName'), variables('customdomain').wwwDomain.name)]",
                            "dependsOn": [
                                "[resourceId('Microsoft.Cdn/profiles/endpoints', variables('cdnProfileName'), variables('cdnEndpointName'))]"
                            ],
                            "properties": {
                                "hostName": "[variables('customdomain').wwwDomain.domain]"
                            }
                        }
                    ]
                }
            ]
        }
    ]
}

You’ll notice there’s 1 input parameter called webEndpoint. This is the primary web endpoint I’ve received from the earlier mentioned PowerShell script. If you make the assumption on the actual web endpoint of the storage account, you can also put all of the resources into a single ARM template. I don’t like to make this assumption, so I’m deploying all of this stuff in 3 little steps.

All of the other values I need in the template are mentioned in the variables section.

The endpoint resource specifies the basics of what your CDN endpoint should do (like compressing, allowing HTTP), etc. The customdomains are necessary to make your website available via a custom domain, like https://jan-v.nl/ instead of https://my-endpoint-name.azureedge.net.

When deploying this template, you’ll probably be notified the custom domain resources can’t be created. To create these resources you need to add one or more CNAME records for your DNS zone.

cdnverify in dns zone

I’m using Cloudflare over here because I love their service and their DNS is super fast! As you can see in the image, I’ve created multiple records. The CNAME record with the name cdnverify.www points to https://cdnverify.my-endpoint-name.azureedge.net. This one is necessary for the www subdomain. The CNAME record with the name cdnverify also points to the https://cdnverify.my-endpoint-name.azureedge.net endpoint. This one is necessary for the root domain.

It’s important to note you should not Proxy these records via Cloudflare, but use the option ‘DNS only’. Proxying the endpoint will fail the DNS verification step when creating the custom domains in Azure CDN.

Once you have added these records, the ARM template can be deployed successfully.

Now you’re ready to add or edit your actual DNS records. In my case, I’ve created 2 new CNAME records. One with the name www, which points to https://my-endpoint-name.azureedge.net and the other with the name @ or jan-v.nl which points to the same endpoint.

And now for the Cloudflare features I’m using

Obviously, I’m using Cloudflare as my DNS provider.

I’m also using it for its Page Rules. While you can use the Rules engine of Azure CDN, I found the documentation a bit unclear and couldn’t get the rules to work (at least not in the way I expected them to work). There’s also the propagation delay which can take up to about 15 minutes before the rules start working. Very annoying when you want to test them.

The Cloudflare page rules work immediately, the documentation is quite clear and they work as I expect them to work.

At this moment, each site/domain gets 3 page rules for free. As it turns out, I only need 2.

The first one is to enforce HTTPS. This is a default rule in Cloudflare.

default https in cloudflare

The second rule I’ve set up is to route traffic from the www-subdomain to the root domain.

route www subdomain to root domain

As you can see, very straightforward. I’m checking if a url matches www.jan-v.nl/* and forward this with a 301 to https://jan-v.nl/$1.

I’m also using the (free) Cloudflare speed optimizations, which are nice but don’t have a lot of effects on the overall page load time.

speed optimizations results of cloudflare

If I’m not mistaken, these results are based on a 3G connection. The Cable speeds are MUCH faster. Also, this speed is comparable to the Miniblog results. Miniblog was only about 0.1 or 0.05 seconds slower compared to this static site, which means Miniblog is really, really fast!

Why are you using these services?

Cloudflare AND Azure CDN?

A couple of days ago someone asked me why I was using Cloudflare AND Azure CDN.

Both of these services can act as a CDN, so this appears to be a bit redundant. Also, it’s perfectly fine to connect Cloudflare to your Azure Storage Account. That way you don’t need the intermediate Azure CDN service.

The main reason I’m (still) using Azure CDN is because at first, I wanted to use this service to host my website and only use Cloudflare as my DNS provider.

As it turns out, I like Cloudflare a bit better compared to Azure CDN. Ditching Azure CDN is a possibility for me, but it doesn’t hurt to have it in-between. It’ll probably save me some Storage Account IOPS in the long run.

Why not use Azure DNS?

As I already mentioned, my site was already using Cloudflare. That’s the main reason I’m still using it.

If I hadn’t used Cloudflare yet, I’d probably go for Azure DNS + Azure CDN combination. Both services offer great capabilities, but for me, there isn’t any reason to migrate. The stuff works and that’s what I expect from my blog.

What’s the actual cost

I’m not running the static site with Azure CDN for long now.

In Azure Cost Management, I’ve looked up what the current cost of my services are for the past 7 days.

weekly costs in Azure

These are 3 different storage accounts, all of them hosting a static website.

The CDN Profile hosts multiple endpoints for each of those sites.

At the moment, the total costs of hosting these sites are about 5 cents per week. If I multiply this by 4 it’ll be about 20 to 25 cents per month to host 3 sites!

I’m using the free Cloudflare service, so this doesn’t cost me a single penny.

Amazing stuff!


Share

comments powered by Disqus