Introduction

Your Bicep templates are probably broken, and you don’t know it yet. Infrastructure as Code can easily create a false sense of confidence when incrementally deploying your infrastructure and applications. Every incremental change passes CI pipelines, it deploys, and everything looks green – it all works. But if you deployed in complete mode, or on a greenfield environment, how confident are you that it would actually succeed? This is where the false sense of security creeps in with incremental changes, and it defeats the whole point of Infrastructure as Code if you don’t fully test the templates in my opinion.
In this blog, I want to dive into the world of ephemeral Bicep environments, why they are crucial for testing your deployments completely, and what that might look like in your Bicep projects. In the blog, we’ll cover:
- Creating an integration test template for the Bicep to deploy from
- Using GitHub Actions to automate and enforce an ephemeral environment to deploy into on pull request
- Leveraging Bicep’s readEnviornmentVariable() function in CI pipelines for overrides
- Using Azure Deployment Stacks to manage the full lifecycle of the ephemeral deployment process
- A basic smoke test example for an App Service
Setup
To get started you’ll need:
- A service principal (App Registration to use for OIDC federated credential)
- An Azure Subscription with SPN assigned as Contributor (Sandbox Subscription or similar for the ephemeral app deployments)
- ALZ Hub, or deploy a demo/dummy hub to test this out
- A GitHub Repository
Azure CI Readiness Setup
This will create a federated credential specifically for our pull request ephemeral deployments into Azure, keeping the scope fine-grained (see entity type).

GitHub Configuration
After you’ve created the federated credential, you’ll want to save the follow into your repository as action variables:
Add these secrets in GitHub → Settings → Secrets and variables → Actions → Variables:
AZURE_CLIENT_IDAZURE_TENANT_IDAZURE_SUBSCRIPTION_ID

Hub Deployment (Optional / Demo)
To mock a network hub in Azure for this demo setup, you’ll need to deploy a mock ‘hub’ virtual network as a deployment stack, so we can mimic a spoke peering in the ephemeral app deployment. I’ve created a demo template you can use if you wish:
Bicep Integration Test Example
In this example, I’ve created a unique test.bicep file that acts as a wrapper module for my main.bicep template. This approach gives me flexibility with parameters (making them entirely unique) to deploy the ephemeral integration/validation test, while keeping my main production ready template and values isolated and separate.

targetScope = 'subscription'
// Parameters for ephemeral deployments
param parLocation string
param parResourceGroupName string
param parHubVirtualNetworkId string
param parHubVirtualNetworkName string
param parAddressSpace string
param parSubnetCidr int
param parSubnetCount int
param parAppSubnetIndex int
param parSubnetNamePrefix string
param parAppServicePlanName string
param parWebAppName string
param parVirtualNetworkName string
param parSqlServerName string
param parSqlDatabaseName string
module test '../main.bicep' = {
name: 'test-app'
scope: subscription()
params: {
parLocation: parLocation
parResourceGroupName: parResourceGroupName
parAppServicePlanName: parAppServicePlanName
parWebAppName: parWebAppName
parVirtualNetworkName: parVirtualNetworkName
parAddressSpace: parAddressSpace
parSubnetCidr: parSubnetCidr
parSubnetCount: parSubnetCount
parAppSubnetIndex: parAppSubnetIndex
parSubnetNamePrefix: parSubnetNamePrefix
parSqlDatabaseName: parSqlDatabaseName
parSqlServerName: parSqlServerName
parHubVirtualNetworkId: parHubVirtualNetworkId
parHubVirtualNetworkName: parHubVirtualNetworkName
}
}BICEPWith a dedicated test.bicepparam file that provides unique values and supports reading GitHub workflow environment variables at runtime during a pull request:
using './test.bicep'
param parLocation = readEnvironmentVariable('AZURE_LOCATION', 'uksouth')
param parResourceGroupName = readEnvironmentVariable('BICEP_RG_NAME', 'rg-app')
param parHubVirtualNetworkId = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hub-dev/providers/Microsoft.Network/virtualNetworks/vnet-hub-dev'
param parHubVirtualNetworkName = 'vnet-hub-dev'
param parAddressSpace = '10.99.0.0/24'
param parSubnetCidr = 26
param parSubnetCount = 2
param parAppSubnetIndex = 0
param parSubnetNamePrefix = 'subnet-app${uniqueString(parResourceGroupName)}'
param parAppServicePlanName = 'asp-app-${uniqueString(parResourceGroupName)}'
param parWebAppName = 'app-web-${uniqueString(parResourceGroupName)}'
param parVirtualNetworkName = 'vnet-app-${uniqueString(parResourceGroupName)}'
param parSqlDatabaseName = 'sqldb-${uniqueString(parResourceGroupName)}'
param parSqlServerName = 'sqlsrv-${uniqueString(parResourceGroupName)}'BICEPreadEnvironmentVariable() function
Bicep has had a function called readEnvironmentVariable() available for over two years now, and it’s very powerful to leverage, especially in CI/CD pipelines. It allows great flexibility in deployments from .env files or global variables set during runs.
In this example, I’m using it to give flexibility on the resource region and resource group for the ephemeral deployment by allowing the resource group to be set to the pull request ID at pipeline runtime, so we can have traceability and uniqueness for that specific PR.
GitHub Workflow
Next is setting up and configuring the workflow with the required triggers, variables, and actions for the ephemeral deployment to run on a pull request. To simplify the deployment lifecycle on pull request, I’m using the Azure Deployment Stack mechanism to deploy and then destroy the resources once the process completes successfully.
Handily, there’s a native GitHub Action for Bicep that supports stacks called Azure/bicep-deploy@v2.
By configuring the pipeline variables, I’m able to take advantage of the readEnvironmentVariable() function from earlier to set these values. Here, I’m setting the resource group to create with a unique name from the pull request number, e.g. rg-pr-96. and also setting a unique deployment stack with the pull request number also, e.g. az-stack-pr-96:
- name: Configure deployment variables
shell: pwsh
env:
GITHUB_PR_NUMBER: ${{ github.event.number }}
run: |
$suffix = "pr-$env:GITHUB_PR_NUMBER"
"APP_STACK_NAME=app-stack-$suffix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"BICEP_RG_NAME=rg-$suffix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -AppendYAMLFull example
Now, using the actions together, you can see an example full workflow below. Once committed to your .github/workflows folder in your repository, it will trigger. It includes:
Log in to Azure with Federated Creds → Config Vars + Set Azure Vars → Deploy ephemeral app infra from test file → Resolve App Service FQDN → Run Smoke Test verification on root → Delete ephemeral app infra:
name: PR Infra Validation
on:
pull_request:
branches:
- main
jobs:
deploy-and-smoke-test:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
actions: read
security-events: write
pull-requests: write
env:
AZURE_LOCATION: uksouth
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure login
id: azure-login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Configure deployment variables
shell: pwsh
env:
GITHUB_PR_NUMBER: ${{ github.event.number }}
run: |
$subscriptionId = az account show --query id -o tsv
$suffix = "pr-$env:GITHUB_PR_NUMBER"
"APP_STACK_NAME=app-stack-$suffix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"BICEP_RG_NAME=rg-$suffix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Deploy app stack
id: bicep-deploy
uses: Azure/bicep-deploy@v2
with:
type: deploymentStack
operation: create
name: ${{ env.APP_STACK_NAME }}
location: ${{ env.AZURE_LOCATION }}
scope: subscription
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
template-file: infra/app/.tests/test.bicep
parameters-file: infra/app/.tests/test.bicepparam
action-on-unmanage-resources: delete
action-on-unmanage-resourcegroups: delete
deny-settings-mode: none
- name: Resolve web app hostname
shell: pwsh
run: |
$hostname = "${{ steps.bicep-deploy.outputs.outWebAppDefaultHostName }}"
if (-not $hostname) {
Write-Error "Failed to resolve web app hostname from deployment outputs"
exit 1
} else {
Write-Host "Resolved web app hostname: $hostname"
}
"WEBAPP_HOSTNAME=$hostname" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Smoke test web app
shell: pwsh
run: |
$retries = 5
$delay = 5
$url = "https://$env:WEBAPP_HOSTNAME/"
for ($i = 1; $i -le $retries; $i++) {
Write-Host "[$i/$retries] Probing $url"
try {
$response = Invoke-WebRequest -Uri $url -UseBasicParsing
Write-Host "[$i/$retries] Received status $($response.StatusCode)"
if ($response.StatusCode -eq 200) {
Write-Host "Smoke test succeeded"
exit 0
}
Write-Warning "[$i/$retries] Unexpected status code; retrying"
} catch {
Write-Warning "[$i/$retries] Request failed: $($_.Exception.Message)"
if ($i -eq $retries) { throw }
Start-Sleep -Seconds $delay
continue
}
Start-Sleep -Seconds $delay
}
- name: Delete deployment stack
if: always() && steps.azure-login.outcome == 'success' && steps.bicep-deploy.outcome == 'success'
uses: Azure/bicep-deploy@v2
with:
type: deploymentStack
operation: delete
name: ${{ env.APP_STACK_NAME }}
location: ${{ env.AZURE_LOCATION }}
scope: subscription
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
template-file: infra/app/.tests/test.bicep
parameters-file: infra/app/.tests/test.bicepparam
action-on-unmanage-resources: delete
action-on-unmanage-resourcegroups: delete
deny-settings-mode: noneYAMLWhat does it look like on run?
Now, when you create a pull request against your main branch, it will trigger the workflow. Ephemeral infrastructure is deployed through a deployment stack with our PR’s unique number:

Then, a temporary resource group is created with the same unique pull request number, deploying the ephemeral infrastructure app components with their unique values from readEnvironmentVariable() and the bicepparam file.

Once the stack has successfully deployed, the workflow moves on to a basic smoke test to confirm the App Service is set up correctly and that the hello world or setup page at the root returns a 200 response.
If there’s an issue with your configuration or app settings, the deployment may not fail, but the App Service could be non-functional.
This is where a test like this can help:

As you can see above, on success or failure the last stage is the deletion of the stack, which removes the ephemeral environment. This is where the power of Azure Deployment Stacks comes into play, helping you manage the lifecycle of an ephemeral setup like this by allowing you to remove all resources when needed.
Static Code Analysis?
I’ve previously blogged about tools like PSRule before, so I’d encourage reading that before if you are unfamiliar with the tool and its setup + configuration.
You can and should incorporate static code analysis into a process like this. It has a multitude of benefits:
- Guard against misconfigurations
- Enforce governance and best practices with WAF/CAF alignment
- Adhere to security best practices
- Custom rules and organisation-specific alignment
Incorporating PSRule into the ephemeral run is a good idea. However, you’ll likely want to have a specific file for PSRule, as it will require mocked default values for all parameters in order to successfully expand the template and evaluate against the ruleset (and so don’t lend as well for actual deployment tests):

There is an official GitHub Action for PSRule that can be run, for example:
- name: Run PSRule analysis
uses: microsoft/[email protected]
with:
modules: 'PSRule.Rules.Azure'
inputType: 'repository'
inputPath: 'infra/'
outputFormat: 'Markdown'
outputPath: 'reports/ps-rule-results.md'
option: 'ps-rule.yaml'
summary: true
continue-on-error: trueYAMLThen you can see a summary on the run:
I need to review my template ❌

Looks good! ✅

If wondering, why not use the same file for both the integration/smoke test and static code analysis? In my experience the file that you need to deploy on ephemeral PR will want flexibility in the bicepparam file for overrides (readEnvironmentVariable), etc.
Conclusion
Infrastructure as Code makes it easy to fall into a false sense of security when doing incremental changes for a long time. Complete deployments and smoke tests like my example can help reset that habit and introduce a more mature validation approach to act as a guardrail.
In my opinion, this is what true Infrastructure as Code is about it needs to truly reflect what you are defining as a declarative language. Your use cases and tests will vary in complexity and how far you go. This blog was just to inspire and illustrate an example, but there’s a lot you could add: more smoke tests (checking DB access, App Insights logging is functional, etc.). I think this approach is really interesting and powerful for ensuring complete deployments align with your main production code.
I’d be interested to hear how you’re doing this or if not, what your thoughts are? Drop a comment! Thanks for reading.