Background
Modernising legacy applications is always a journey, and recently I tackled moving an existing WCF web service into Azure App Service. If you’ve worked with WCF, you’ll know the web.config
file is the nerve centre, handling everything from parameters to connection strings.
But here’s the catch, this service needs to run in multiple environments (Development, Test, and Production) each with its own unique settings. That means multiple config files:
- Web.Dev.Config
- Web.Test.Config
- Web.Production.Config
The challenge
I didn’t want to build and maintain three separate web packages, one for each environment. That’s a recipe for drift and deployment headaches. Instead, I wanted a single build artifact that could be promoted through environments, swapping out only the environment-specific config as needed.
Enter web.config transformations. With these, you can:
- Keep a base
web.config
- Layer on environment-specific transforms (
Web.Dev.Config
, etc.)
But when should the transformation happen? There are two main options, each with its own pros and cons:
-
At build time (using MSBuild):
- The transformation is applied as part of the build process, so the output artifact already contains the environment-specific configuration.
- This approach is simple if you only ever deploy to one environment per build, but it means you need to build a separate artifact for each environment. This breaks the principle of binary promotion, where the same artifact is promoted through Dev, Test, and Production. It also increases the risk of inconsistencies between builds.
-
At deployment (using the AzureRMWebAppDeployment@4 Azure DevOps Task):
- The transformation is applied as part of the deployment process, allowing you to use a single, environment-agnostic build artifact and inject the correct configuration at deploy time.
- This is the preferred approach for most modern pipelines, as it enables true binary promotion, reduces build times, and ensures that what you test is exactly what you deploy to production. It also aligns with best practices for repeatable, reliable deployments.
Solution: deploy-time transforms
Here’s how I set it up:
Build pipeline
# YAML
...
stages:
- stage: 'Build_Packages'
jobs:
- job: 'Build_WCFService'
steps:
- checkout: self
fetchDepth: 0
- task: VSBuild@1
displayName: 'Build the WCF Service Solution'
inputs:
solution: '$(Build.SourcesDirectory)\*.sln'
msbuildArgs: '/p:OutputPath=$(Build.ArtifactStagingDirectory)\WCFService /p:IsTransformWebConfigDisabled=true'
platform: 'any cpu'
vsVersion: 'latest'
configuration: 'Release'
clean: true
- task: ArchiveFiles@2
displayName: 'Archive WCF Service Build Output'
inputs:
rootFolderOrFile: '$(Build.ArtifactStagingDirectory)\WCFService\_PublishedWebsites\WCFService.Web'
includeRootFolder: false
archiveType: 'zip'
archiveFile: '$(Build.ArtifactStagingDirectory)\WCFService\WCFService.zip'
replaceExistingArchive: true
- task: PublishPipelineArtifact@1
displayName: 'Publish WCF Service Build Artifact'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)\WCFService\WCFService.zip'
publishLocation: 'pipeline'
artifactName: 'WCFService'
Release pipeline
Each environment (Dev, Test, Production) will get its own stage. The key is to ensure the correct environment name is set so the right transform is applied.
# YAML
...
- stage: 'Dev'
displayName: 'Deploy to Dev Environment'
variables:
- group: 'Dev'
dependsOn: 'Build_Packages'
condition: succeeded()
jobs:
- deployment: Deploy_to_Dev
environment: WCFServiceDev
strategy:
runOnce:
deploy:
steps:
- template: EnvironmentDeploy.azurepipelinetemplate.yml
parameters:
ARMConn: 'Dev'
- stage: 'Test'
displayName: 'Deploy to Test Environment'
variables:
- group: 'Test'
dependsOn: 'Dev'
condition: succeeded()
jobs:
- deployment: Deploy_to_Test
environment: WCFServiceTest
strategy:
runOnce:
deploy:
steps:
- template: EnvironmentDeploy.azurepipelinetemplate.yml
parameters:
ARMConn: 'Test'
- stage: 'Production'
displayName: 'Deploy to Production Environment'
variables:
- group: 'Prod'
dependsOn: 'Test'
condition: succeeded()
jobs:
- deployment: Deploy_to_Production
environment: WCFServiceProd
strategy:
runOnce:
deploy:
steps:
- template: EnvironmentDeploy.azurepipelinetemplate.yml
parameters:
ARMConn: 'Production'
Deployment template
#YAML
steps:
- download: current
displayName: 'Download the wcf Service Web package'
artifact: WCFService
...
- task: AzureRMWebAppDeployment@4
displayName: 'Deploy WCF Service Web App'
inputs:
ConnectionType: 'AzureRM'
azureSubscription: ${{ parameters.ARMConn }}
appType: 'webApp'
WebAppName: '$(armOutput.AppServiceName)'
Package: '$(Pipeline.Workspace)/WCFService/WCFService.zip'
enableXmlTransform: true
Here’s the bit that tripped me up: in multi-stage pipelines, the Release.EnvironmentName
variable isn’t set by default. Without it, Azure DevOps doesn’t know which transform to apply. The fix? Explicitly set the variable in each stage:
# YAML
- stage: 'Dev'
displayName: 'Deploy to Dev Environment'
variables:
...
- name: 'Release.EnvironmentName'
value: 'Dev'
...
- stage: 'Test'
displayName: 'Deploy to Test Environment'
variables:
...
- name: 'Release.EnvironmentName'
value: 'Test'
...
- stage: 'Production'
displayName: 'Deploy to Production Environment'
variables:
...
- name: 'Release.EnvironmentName'
value: 'Production'
...
Troubleshooting and tips
Common pitfalls:
- Ensure your transform files (e.g.,
Web.Dev.config
) are included in your build artifacts. If they’re missing, check your.csproj
or project file to confirm they’re marked asContent
and set toCopy to Output Directory
if needed. - File names are case-sensitive on some systems, double-check spelling and casing.
- If transforms aren’t being applied, verify that the
Release.EnvironmentName
variable is set correctly and matches your transform file naming.
Security note: Avoid storing secrets or sensitive values directly in your config files. Use Azure Key Vault or pipeline secrets for sensitive data, and reference them via environment variables or pipeline variables where possible.
Wrapping up
With this approach, you get a single, reusable build artifact and environment-specific configuration at deployment, no more juggling multiple packages. It’s a small change that makes your pipeline cleaner and your deployments more reliable.
Hope this helps, happy deploying!