Zero
Zero
Back

Deploying Azure Functions with Pulumi and Zero

In this post, we'll use Pulumi to define our application's Azure infrastructure using clean and declarative TypeScript code.

Sam Magura

Sam Magura

An abstract shape

Pulumi is an Infrastructure as Code (IaC) platform that enables you to deploy to AWS, Azure, and Google Cloud Platform. The Pulumi toolchain is vendor agnostic, so you won't have to relearn any core Pulumi concepts if you decide to switch to a different cloud provider.

Pulumi is similar to Terraform  in many ways, but with one key difference. While Terraform is typically written in a domain-specific language called HCL , Pulumi allows you to define your infrastructure using a programming language you are already familiar with. Pulumi currently supports JavaScript, TypeScript, Python, Go, C#, Java, and YAML. Click here  to see an example of how Pulumi works in each of these languages.

Not having to learn yet another language is a huge convenience factor. Having the full power of a general purpose programming language at your disposal also makes it much easier to add logic into your infrastructure templates. For example, you might want a specific resource to exist only in the production system, not in the staging system. With Pulumi, implementing this is as simple as writing an if statement.

What We're Building

This article will walk you through using Pulumi to deploy an Azure Functions  project to Microsoft Azure. We'll store both the Pulumi and Azure credentials in the Zero secrets manager. The credentials will be retrieved from Zero in our deployment script, and then Pulumi will handle the rest.

Both the Pulumi code and the Azure Functions project will be written in TypeScript. For more background on Azure Functions, check out my previous blog post where I showed how to call the Twilio SMS API from an Azure Function App.

🔗 The full code for this example is available in the zerosecrets/examples  GitHub repository.

Installing the Prerequisites

Here's what you'll need to get started:

  1. An active Azure subscription
  2. The Azure CLI . Once it's installed, run az login to authenticate.
  3. The Azure Functions Core Tools 
  4. Pulumi  installed on your local PC.

Creating the Pulumi Project

To bootstrap a new Pulumi project, create a new directory called pulumi-azure-functions and cd into it. Then run

1
pulumi new azure-typescript
shell

You'll be prompted to log in to Pulumi and then asked a few questions about your project. While we eventually want to authenticate with Pulumi via an access token that is stored in Zero, for now, it's easiest to just log in via the web.

Pulumi will populate your directory with a package.json, a Pulumi.yaml, and an index.ts, among other files. index.ts is where you'll define the Azure resources needed to deploy your project. The azure-typescript template defines a resource group and a storage account:

1
2
3
4
5
6
7
8
9
10
11
// Create an Azure Resource Group
const resourceGroup = new resources.ResourceGroup('resourceGroup')

// Create an Azure resource (Storage Account)
const storageAccount = new storage.StorageAccount('sa', {
  resourceGroupName: resourceGroup.name,
  sku: {
    name: storage.SkuName.Standard_LRS,
  },
  kind: storage.Kind.StorageV2,
})
typescript

If you like, you can run pulumi up to test that everything is working. The resource group and storage account won't do anything interesting on their own, but we will need them later for our Azure Function App.

Creating the Azure Functions Project

Now let's create an Azure Functions Project as a subdirectory inside pulumi-azure-functions. In the pulumi-azure-functions directory, run

1
func init MyFunctionProject
shell

and select Node and TypeScript at the prompts. Then cd into MyFunctionProject and run npm install.

The Functions project is initially empty, so run

1
func new
shell

to scaffold a new function, and select "HTTP trigger" for the type of function. Azure Functions supports a wide variety of triggers — we're using an HTTP-triggered function simply because it is the easiest to test with. For my function, I used the default name of HttpTrigger.

Now you can run

1
npm start
shell

to run the Functions project locally. Go to http://localhost:7071/api/HttpTrigger  in your browser and you'll see a "hello world" message from your function.

Declare the Azure Function App as a Pulumi Resource

Now let's get the Functions project deployed to Azure. To do this, we'll edit the index.ts file to tell Pulumi about the Azure resources that make up our application. The following code is based on this example  provided by Pulumi.

The Functions project will be deployed as a zip file, so we'll need a storage container to house the zip:

1
2
3
4
const codeContainer = new storage.BlobContainer('zips', {
  resourceGroupName: resourceGroup.name,
  accountName: storageAccount.name,
})
typescript

Then tell Pulumi to upload the MyFunctionProject archive to the storage container:

1
2
3
4
5
6
const codeBlob = new storage.Blob('zip', {
  resourceGroupName: resourceGroup.name,
  accountName: storageAccount.name,
  containerName: codeContainer.name,
  source: new pulumi.asset.FileArchive('./MyFunctionProject'),
})
typescript

Next, define the App Service Plan that will host our Function App. We'll use a Consumption Plan, which is the true serverless option for Azure Functions where you only pay for what you use.

1
2
3
4
5
6
7
const plan = new web.AppServicePlan('plan', {
  resourceGroupName: resourceGroup.name,
  sku: {
    name: 'Y1',
    tier: 'Dynamic',
  },
})
typescript

The next part is the most complicated. We need to provide the Function App with the storage account connection string and the URL for our code zip file as app settings. These values can't be hardcoded since they depend on things like the unique name of our storage account, which is generated by Pulumi when you deploy. We'll write the code to get the connection string and blob URL in a new file called helpers.ts  — click that link to see the full source of this file.

With the helpers in place, we can define the Azure Function App:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const storageConnectionString = getConnectionString(resourceGroup.name, storageAccount.name)
const codeBlobUrl = signedBlobReadUrl(codeBlob, codeContainer, storageAccount, resourceGroup)

const app = new web.WebApp('fa', {
  resourceGroupName: resourceGroup.name,
  serverFarmId: plan.id,
  kind: 'functionapp',
  siteConfig: {
    appSettings: [
      {name: 'AzureWebJobsFeatureFlags', value: 'EnableWorkerIndexing'},
      {name: 'AzureWebJobsStorage', value: storageConnectionString},
      {name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4'},
      {name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node'},
      {name: 'WEBSITE_NODE_DEFAULT_VERSION', value: '~18'},
      {name: 'WEBSITE_RUN_FROM_PACKAGE', value: codeBlobUrl},
    ],
    http20Enabled: true,
    nodeVersion: '~18',
  },
})
typescript

As a convenience, we'll have Pulumi output the URL of our HTTP-triggered function:

1
export const endpoint = pulumi.interpolate`https://${app.defaultHostName}/api/HttpTrigger`
typescript

Manual Deployment

Now that our infrastructure is defined, let's test it out. First, compile the Functions project from TypeScript to plain JavaScript:

1
2
cd MyFunctionsProject
npm run build
shell

Then return to the pulumi-azure-functions directory and run

1
pulumi up
shell

to deploy to Azure. It really is as simple as that.

Our function is configured with function-level authorization by default, so you'll get an HTTP 401 error if you attempt to visit https://<HOST_NAME>/api/HttpTrigger in your browser. The easiest way to make an authorized request to the function is to copy the function key from Azure Portal and include that in the URL.

In Azure Portal, navigate to your Function App and select Functions > HttpTrigger. Then click "Function keys" in the menu on the left and copy the function key.

Accessing the function key in Azure Portal

Now go to

1
https://<HOST_NAME>/api/HttpTrigger?code=<FUNCTION_KEY>
shell

in your web browser and you'll see a successful response from the function!

Automating the Process with a Deployment Script

Our end goal is to deploy the Functions project from a continuous integration system, using Zero to fetch both the Pulumi and Azure credentials. To make this happen, we'll need a deployment script that calls Zero and then passes the secrets returned by Zero to Pulumi. The deployment script should also build the Functions project, so that you never accidentally deploy an old version of the code if you forget to run npm run build before the deployment.

I'll be writing my deployment script as a Node.js script using zx , though you could implement the same functionality as a series of steps in the YAML file that defines your CI workflow. I like implementing the deployment process as a script since you can run the script locally while you're actively working on it. This saves a ton of time because you don't have to wait 10 minutes for your CI workflow to run each time you make a change.

Once the deployment script is complete, you can integrate it into your CI workflow. Since the script only depends on Node.js and zx, you can easily run it from any CI platform, whether that be GitHub Actions, Azure Pipelines, or CircleCI.

Deployment Script v0

Let's write a simple initial version of the deployment script that just builds the Functions project and runs pulumi up. To get started with zx , simply install the zx npm package. It can either be installed globally or as a dev dependency of your project. I'll add zx as a dev dependency of my project to keep everything neatly encapsulated:

1
npm install --save-dev zx
shell

💡 The package should be installed in the pulumi-azure-functions directory, not the MyFunctionProject directory.

Then add a package.json script:

1
2
3
"scripts": {
  "deploy": "zx deploy.mjs"
},
json

pulumi-azure-functions/deploy.mjs is where our code will go. For this initial version, we simply build the the Functions project and then run pulumi up:

1
2
3
4
5
6
7
8
9
10
#!/usr/bin/env zx

// TODO Call Zero to get the Pulumi and Azure credentials

cd('MyFunctionProject')
await $`npm run build`

cd('..')

await $`pulumi up --skip-preview`
javascript

The $ syntax shown here is a feature of zx which makes it super convenient to run shell commands from your Node.js script.

To test the deployment script, simply run

1
npm run deploy
shell

Adding the Pulumi Access Token to Zero

To get a Pulumi access token, log in to your account at https://app.pulumi.com/ . Click your profile picture in the upper right corner, and select "Personal access tokens". Create a new token and copy it into the Zero secrets manager as shown here:

Adding a PULUMI_ACCESS_TOKEN secret to Zero

Creating an Azure Service Principal

To grant Pulumi access to our Azure subscription, we need to create a Service Principal in Azure. You can create one by following the steps below. Refer to this page  in the official Azure docs for more details.

  1. Log in to Azure Portal and navigate to Azure Active Directory.
  2. Create an App Registration named "MyFunctionProject".
  3. Create a new client secret for the App Registration. Copy the client secret.

Now, create a new Azure secret in your Zero project. The Azure secret should have the following fields:

  • CLIENT_ID — From the App Registration. Also known as the Application ID.
  • CLIENT_SECRET — From the App Registration.
  • TENANT_ID — Your Azure Active Directory tenant ID. Can be copied from the Overview page of the App Registration.
  • SUBSCRIPTION_ID — To find your subscription's ID, search for "Subscriptions" in the main search bar of Azure Portal.

After creating the secret in Zero, the next step is to grant the Service Principal access to create and modify resources within the Resource Group that contains your Function App.

  1. Navigate to the Resource Group in Azure Portal.
  2. Select "Access control (IAM)" and then click "Add role assignment".
  3. Select "Privileged administrator roles" for assignment type.
  4. Select the Contributor role which gives full write access to the Resource Group.
  5. Select the "MyFunctionProject" Service Principal on the Members tab.
  6. Submit the form to create the role assignment.

Integrating with Zero

With our secrets stored securely in Zero, it's time to update the deployment script to retrieve the secrets from Zero and then pass them to the Pulumi CLI. First, install the Zero JavaScript SDK:

1
npm install --save-dev @zerosecrets/zero
shell

Then, in deploy.mjs, import the Zero SDK and pull down the Azure and Pulumi secrets:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {zero} from '@zerosecrets/zero'

if (!process.env.ZERO_TOKEN) {
  throw new Error('Did you forget to set the ZERO_TOKEN environment variable?')
}

const secrets = await zero({
  token: process.env.ZERO_TOKEN,
  pick: ['azure', 'pulumi'],
  callerName: 'development',
}).fetch()

const azureClientId = secrets.azure.client_id
const azureClientSecret = secrets.azure.client_secret
const azureTenantId = secrets.azure.tenant_id
const azureSubscriptionId = secrets.azure.subscription_id
const pulumiAccessToken = secrets.pulumi.pulumi_access_token
javascript

Then use pulumi config set to tell Pulumi to use our Service Principal to authenticate with Azure:

1
2
3
4
5
6
7
8
9
10
await $`pulumi config set azure-native:clientId ${azureClientId}`

// zx prints the commands you run by default. Do not print the next command
// since it contains our client secret
$.verbose = false
await $`pulumi config set azure-native:clientSecret ${azureClientSecret} --secret`
$.verbose = true

await $`pulumi config set azure-native:tenantId ${azureTenantId}`
await $`pulumi config set azure-native:subscriptionId ${azureSubscriptionId}`
javascript

See this page  in the Pulumi docs for more information about this step.

Finally, we set the PULUMI_ACCESS_TOKEN environment variable so that Pulumi has our personal access token:

1
process.env.PULUMI_ACCESS_TOKEN = pulumiAccessToken
javascript

ℹ️ Before you test the updated deployment script, make sure to log out of both the Pulumi and Azure CLIs:

1
2
pulumi logout
az logout
shell

Your CI system won't be logged into either CLI, so we need to log out locally to test the script properly.

The deployment script can be run the same way as before, but now you'll need to pass your Zero token in as an environment variable:

1
ZERO_TOKEN='<YOUR_ZERO_TOKEN>' npm run deploy
shell

If everything is configured properly, Pulumi will update your Azure resources and your Function App code to match what you have locally!

Clean up the Azure Resources

While all of the Azure resources used in this walkthrough are virtually free, it's still a best practice to delete the resources when you are done so that there are not any unexpected charges. To delete all of the Azure resources, simply run

1
pulumi destroy
shell

You should log in to the Pulumi CLI again if you ran pulumi logout earlier.

Next Steps

This guide showed you how to deploy a serverless Azure Functions application to the cloud, using Pulumi for Infrastructure as Code and Zero for secure secrets storage.

If you are building a production application using these tools, the next step would be to run the deployment script as part of your continuous integration workflow. This should be straightforward regardless of which CI platform you are using. That said, make sure that you give the CI workflow access to your Zero token in a secure way. Instead of including the Zero token in the workflow's YAML file, add it as a secret environment variable. If using GitHub Actions, for example, you would add the Zero token in the "Secrets and variables" section of your repository's settings page. The exact terminology used will vary between CI platforms.

Happy coding!