Managing Environments in CI/CD Pipelines
Environment management is the part of CI/CD that prevents dev changes from reaching production before they are ready. Done well, it gives you fast feedback loops in lower environments and solid confidence gates before production. This page covers the patterns that work at scale: environment isolation, variable scoping, approval gates, and pipeline stages that enforce sequential promotion.
Environment isolation strategies
The level of isolation between environments trades cost for safety. Three common patterns:
- Shared subscription, separate resource groups. Dev, staging, and prod all exist in the same Azure subscription but in separate resource groups with different IAM permissions. Cheapest option, lowest isolation. A misconfigured subscription-level policy or an admin mistake can affect all environments. Acceptable for small teams with low-risk applications.
- Separate subscriptions per environment. Dev, staging, and prod each get their own Azure subscription. Strong isolation boundary — subscription-level quotas, policies, and billing are completely independent. The correct approach for production workloads. Use Azure Management Groups to apply governance policies across all subscriptions consistently.
- Separate Azure AD tenants. Extreme isolation for compliance-heavy scenarios where even admin access must be separated. Rarely needed, significant management overhead. Typically reserved for scenarios where regulatory requirements mandate complete tenant-level separation.
Variable groups per environment
Environment-specific configuration (connection strings, API endpoints, feature flags, resource names) should come from variable groups, not from hardcoded pipeline YAML. Each environment gets its own variable group containing values specific to that environment:
# Pipeline references environment-specific variable groups
stages:
- stage: DeployDev
variables:
- group: vg-myapp-dev # Contains: APP_URL, DB_CONNECTION_STRING, etc.
jobs:
- deployment: DeployApp
environment: env-dev
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to $(APP_URL)"
- stage: DeployStaging
dependsOn: DeployDev
variables:
- group: vg-myapp-staging
jobs:
- deployment: DeployApp
environment: env-staging
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to $(APP_URL)"
- stage: DeployProd
dependsOn: DeployStaging
variables:
- group: vg-myapp-prod
jobs:
- deployment: DeployApp
environment: env-prod
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to $(APP_URL)"The same pipeline YAML deploys to all three environments. Only the variable group changes per stage. This pattern ensures you are deploying the same artifact with environment-appropriate configuration, not different code per environment.
Azure DevOps Environments with approvals
Azure DevOps Environments are the mechanism for approval gates. Create an environment for each deployment target, then configure checks on that environment:
In Azure DevOps, navigate to Pipelines > Environments, create an environment named env-staging and another named env-prod. On env-prod, add an Approvals check requiring sign-off from specific users or groups before any pipeline can deploy to it.
When the pipeline reaches the stage that deploys to env-prod, it pauses and notifies the designated approvers. The deployment does not proceed until someone approves it. This creates a human gate between staging validation and production deployment.
Beyond manual approvals, Azure DevOps Environments support other check types: Invoke Azure Function (run custom validation logic), Query Azure Monitor Alerts (block if there are active alerts on a resource), Branch control (only allow deployments from specific branches), and Business hours checks (only deploy during approved time windows).
Service connections per environment
Each environment should use a separate Azure service connection configured with a separate service principal or managed identity. The prod service connection should have minimal permissions — only what is needed to deploy, nothing else.
stages:
- stage: DeployDev
jobs:
- deployment: DeployApp
environment: env-dev
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'sc-myapp-dev' # Dev service connection
appName: 'app-myapp-dev'
package: '$(Pipeline.Workspace)/drop/*.zip'
- stage: DeployProd
dependsOn: DeployStaging
jobs:
- deployment: DeployApp
environment: env-prod
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'sc-myapp-prod' # Separate prod service connection
appName: 'app-myapp-prod'
package: '$(Pipeline.Workspace)/drop/*.zip'Building once, deploying everywhere
A critical principle: build the application artifact once in a build stage, publish it, and then deploy that same artifact to dev, staging, and production in sequence. Never rebuild the artifact per environment. If you rebuild, you risk subtle differences introduced by dependency version resolution or environment-specific build settings. The artifact that passed staging tests must be byte-for-byte identical to what goes to production.
stages:
- stage: Build
jobs:
- job: BuildApp
pool:
vmImage: ubuntu-latest
steps:
- task: DotNetCoreCLI@2
inputs:
command: publish
publishWebProjects: true
arguments: '--configuration Release --output $(Build.ArtifactStagingDirectory)'
- publish: $(Build.ArtifactStagingDirectory)
artifact: drop
- stage: DeployDev
dependsOn: Build
jobs:
- deployment: Deploy
environment: env-dev
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: drop
- task: AzureWebApp@1
inputs:
azureSubscription: 'sc-myapp-dev'
appName: 'app-myapp-dev'
package: '$(Pipeline.Workspace)/drop/**/*.zip'Common mistakes
- Putting environment-specific configuration in pipeline YAML. Hardcoding values like database hostnames or API keys in YAML means that YAML must be modified to change any environment’s configuration. Every config change becomes a code change, creating noise in commit history and requiring a deployment to take effect. Store all environment-specific values in variable groups and reference them by group name in the pipeline.
- Building the application separately for each environment. Rebuilding from source for each environment introduces risk. A dependency that resolves differently, a build tool version bump, or a timestamp embedded in the binary will produce a different artifact for each environment. Build once, publish the artifact, and deploy the same artifact through all environments.
- Skipping staging and deploying directly from dev to prod under time pressure. The most common cause of production incidents is bypassing the staging environment during urgent deployments. If staging approval gates feel like a blocker during an incident, the right solution is to speed up staging validation (automate smoke tests) — not to remove the gate. A failed deployment to production under pressure is worse than the original incident.
Summary
- Use separate subscriptions per environment for strong isolation. Separate resource groups within one subscription is cheaper but weaker.
- Variable groups per environment hold configuration values. The same pipeline YAML deploys to all environments — only the variable group reference changes.
- Azure DevOps Environments host approval checks, branch controls, and deployment history. Configure a manual approval check on the production environment.
- Build once in a dedicated Build stage, publish the artifact, and deploy the same artifact through dev, staging, and production in sequence.
- Use separate service connections per environment with minimal permissions for each deployment target.
Frequently asked questions
What is an Azure DevOps Environment?
An Azure DevOps Environment is a resource that represents a deployment target — it can contain Azure VMs, Kubernetes namespaces, or be empty (used purely as a checkpoint). Environments track deployment history, showing which pipeline run deployed what version and when. They also host approval checks and branch control policies that gate deployments to that environment.
How do I prevent a pipeline from deploying to production without going through staging first?
Use the dependsOn keyword in your pipeline YAML so the production stage only runs after the staging stage succeeds. Add a branch control check on the production environment in Azure DevOps requiring the source branch to be main. This prevents feature branches from bypassing staging and deploying directly to production.
Can different environments use different Azure subscriptions?
Yes, and this is often a good practice. Keeping dev, staging, and prod in separate subscriptions creates a hard isolation boundary. A misconfigured Azure Policy, accidental deletion, or runaway script in dev cannot affect prod. Configure a separate Azure DevOps service connection per subscription and reference the correct service connection in each stage.