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
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.
Secure your secrets conveniently
Zero is a modern secrets manager built with usability at its core. Reliable and secure, it saves time and effort.
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:
- An active Azure subscription
- The Azure CLI . Once it's installed, run
az login
to authenticate. - The Azure Functions Core Tools
- 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
pulumi new azure-typescript
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:
// 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,
})
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
func init MyFunctionProject
and select Node and TypeScript at the prompts. Then cd
into MyFunctionProject
and run npm install
.
The Functions project is initially empty, so run
func new
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
npm start
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:
const codeContainer = new storage.BlobContainer('zips', {
resourceGroupName: resourceGroup.name,
accountName: storageAccount.name,
})
Then tell Pulumi to upload the MyFunctionProject
archive to the storage container:
const codeBlob = new storage.Blob('zip', {
resourceGroupName: resourceGroup.name,
accountName: storageAccount.name,
containerName: codeContainer.name,
source: new pulumi.asset.FileArchive('./MyFunctionProject'),
})
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.
const plan = new web.AppServicePlan('plan', {
resourceGroupName: resourceGroup.name,
sku: {
name: 'Y1',
tier: 'Dynamic',
},
})
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:
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',
},
})
As a convenience, we'll have Pulumi output the URL of our HTTP-triggered function:
export const endpoint = pulumi.interpolate`https://${app.defaultHostName}/api/HttpTrigger`
Manual Deployment
Now that our infrastructure is defined, let's test it out. First, compile the Functions project from TypeScript to plain JavaScript:
cd MyFunctionsProject
npm run build
Then return to the pulumi-azure-functions
directory and run
pulumi up
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.
Now go to
https://<HOST_NAME>/api/HttpTrigger?code=<FUNCTION_KEY>
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:
npm install --save-dev zx
💡 The package should be installed in the
pulumi-azure-functions
directory, not theMyFunctionProject
directory.
Then add a package.json
script:
"scripts": {
"deploy": "zx deploy.mjs"
},
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
:
#!/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`
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
npm run deploy
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:
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.
- Log in to Azure Portal and navigate to Azure Active Directory.
- Create an App Registration named "MyFunctionProject".
- 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.
- Navigate to the Resource Group in Azure Portal.
- Select "Access control (IAM)" and then click "Add role assignment".
- Select "Privileged administrator roles" for assignment type.
- Select the Contributor role which gives full write access to the Resource Group.
- Select the "MyFunctionProject" Service Principal on the Members tab.
- 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:
npm install --save-dev @zerosecrets/zero
Then, in deploy.mjs
, import the Zero SDK and pull down the Azure and Pulumi secrets:
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
Then use pulumi config set
to tell Pulumi to use our Service Principal to authenticate with Azure:
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}`
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:
process.env.PULUMI_ACCESS_TOKEN = pulumiAccessToken
ℹ️ Before you test the updated deployment script, make sure to log out of both the Pulumi and Azure CLIs:
Terminalpulumi logout az logout
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:
ZERO_TOKEN='<YOUR_ZERO_TOKEN>' npm run deploy
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
pulumi destroy
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!
Other articles
Securely Handle Secrets in CI/CD with the Zero GitHub Action
Exchange your Zero token for secrets right from your GitHub Actions YAML.
CI/CD Integrations Now Live: GitHub Actions, GitLab, Bitbucket
Zero has just launched new integrations for your favorite CI/CD platforms, which allow you to effortlessly sync your secrets.
Secure your secrets
Zero is a modern secrets manager built with usability at its core. Reliable and secure, it saves time and effort.