Automating Ephemeral QA Environments in Azure Container Apps
This guide provides a complete end-to-end workflow for setting up a "Vanish" pipeline. The pipeline builds a Docker image, deploys it to a temporary QA environment for testing, and then deletes the resources immediately — zero waste, zero forgotten environments sitting around running up your bill at the end of the month.
Core Components and Requirements
To successfully implement this workflow, you need the following infrastructure:
- •Azure Container Registry (ACR): A private registry to store your images.
- •Azure Resource Group: A dedicated group to house your environment (e.g.,
rg-qa-environments). - •Azure DevOps Project: A project to host your YAML pipelines.
- •ACR Admin Credentials: In the Azure Portal, go to your ACR > Access Keys and enable Admin User to obtain the username and password.
Step 1: Create the Service Connection
Create a connection that allows Azure DevOps to manage resources in your Azure subscription.
- •In Azure DevOps, go to Project Settings > Service Connections
- •Click New service connection > Azure Resource Manager
- •Select Workload Identity Federation (automatic)
- •Select your Subscription and Resource Group
- •Name it (e.g.,
azure-qa-connection) - •Check Grant access permission to all pipelines
- •Save
Azure DevOps automatically creates a federated credential — no passwords to manage.
Step 2: Configure Secrets (Azure DevOps Side)
Store your ACR Admin password securely so it's never exposed in logs or code.
- •Go to Pipelines > Library
- •Create a new Variable Group named
acr-credentials - •Add a variable named
ACR_PASSWORD - •Paste your ACR Admin Password and click the Padlock icon to mark it as secret
- •Save the group
Step 3: The Main Pipeline (azure-pipelines.yml)
This is the entry point that triggers on every push and calls the deployment template.
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
- group: acr-credentials
- name: ACR_NAME
value: 'yourregistry'
- name: RESOURCE_GROUP
value: 'rg-qa-environments'
- name: ENVIRONMENT_NAME
value: 'qa-environment'
stages:
- stage: QA
displayName: 'Deploy & Test QA'
jobs:
- job: DeployTest
steps:
- template: deploy-template-qa.yml
parameters:
imageName: 'myapp'
containerName: 'myapp-qa'
port: '3000'
Step 4: The Deployment Template (deploy-template-qa.yml)
This template contains the logic for building, deploying, testing, and cleaning up. The key design decision here is that the cleanup step runs with condition: always() — meaning even if the tests fail, the environment gets deleted. No exceptions.
parameters:
- name: imageName
type: string
- name: containerName
type: string
- name: port
type: string
default: '3000'
steps:
# 1. Build the image in ACR
- task: AzureCLI@2
displayName: 'Build Image in ACR'
inputs:
azureSubscription: 'azure-qa-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az acr build \
--registry $(ACR_NAME) \
--image ${{ parameters.imageName }}:$(Build.BuildId) .
# 2. Deploy the Container App
- task: AzureCLI@2
displayName: 'Deploy Container App'
inputs:
azureSubscription: 'azure-qa-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
set -e
if ! az containerapp env show -n $(ENVIRONMENT_NAME) -g $(RESOURCE_GROUP) &>/dev/null; then
az containerapp env create \
-n $(ENVIRONMENT_NAME) \
-g $(RESOURCE_GROUP) \
--location eastus
fi
APP_NAME="${{ parameters.containerName }}-$(Build.BuildId)"
az containerapp create \
--name "$APP_NAME" \
--resource-group $(RESOURCE_GROUP) \
--environment $(ENVIRONMENT_NAME) \
--image "$(ACR_NAME).azurecr.io/${{ parameters.imageName }}:$(Build.BuildId)" \
--registry-server "$(ACR_NAME).azurecr.io" \
--registry-username "$(ACR_NAME)" \
--registry-password "$(ACR_PASSWORD)" \
--ingress external \
--target-port ${{ parameters.port }}
# 3. Wait for deployment and capture URL
- task: AzureCLI@2
displayName: 'Get App URL'
name: 'DeployStep'
inputs:
azureSubscription: 'azure-qa-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
APP_NAME="${{ parameters.containerName }}-$(Build.BuildId)"
for i in {1..12}; do
FQDN=$(az containerapp show \
-n "$APP_NAME" \
-g $(RESOURCE_GROUP) \
--query properties.configuration.ingress.fqdn -o tsv)
if [ -n "$FQDN" ] && [ "$FQDN" != "null" ]; then
echo "App URL: https://$FQDN"
echo "##vso[task.setvariable variable=QA_URL;isOutput=true]https://$FQDN"
exit 0
fi
echo "Waiting for app to be ready... ($i/12)"
sleep 10
done
echo "Timeout waiting for app"
exit 1
# 4. Run Integration Tests
- script: |
echo "Running tests against $(DeployStep.QA_URL)"
export TARGET_URL="$(DeployStep.QA_URL)"
npm test -- __tests__/integration.test.js
displayName: 'Run Integration Tests'
# 5. Vanish - Always cleanup, even if tests fail
- task: AzureCLI@2
displayName: 'Vanish - Delete App'
condition: always()
inputs:
azureSubscription: 'azure-qa-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
APP_NAME="${{ parameters.containerName }}-$(Build.BuildId)"
echo "Deleting $APP_NAME..."
az containerapp delete \
--name "$APP_NAME" \
--resource-group $(RESOURCE_GROUP) \
--yes
echo "Cleanup complete"
Summary
This pipeline automates the entire lifecycle of ephemeral QA environments:
- •Build - Creates a Docker image in ACR
- •Deploy - Spins up a temporary Container App
- •Test - Runs integration tests against the live environment
- •Vanish - Automatically cleans up all resources (even if tests fail)
You only pay for the few minutes the app is running. No forgotten resources, no surprise costs at the end of the billing cycle.
Tip: The condition: always() on the cleanup step is the critical piece. Without it, a failed test run leaves the environment running indefinitely.
Aziz Jarrar
Full Stack Engineer