Azure DevOps: Why Production Deployments Need a Deployment Job
Environment approval gates in Azure DevOps only apply to deployment jobs, not regular jobs. Here is how to structure multi-stage YAML pipelines so production waits for manual approval and environment variables resolve correctly.
01 Oct 2026, 08:32 UTC

The approval that never happened
You added a manual approval check to your Azure DevOps production environment, pushed a commit, and watched the pipeline sail through to production. No prompt appeared. The issue is not a bug; it is a boundary in the pipeline model. Environment approval gates only apply to deployment jobs. A regular job that references an environment name in a script bypasses the gate entirely.
If you are migrating from classic release pipelines to multi-stage YAML, this distinction is critical. The reliable pattern is to reserve environments for actual deployment work and use deployment jobs with a strategy such as runOnce. That way, approvals, environment-scoped variables, and deployment history all behave as expected.
Regular jobs vs. deployment jobs
Azure DevOps treats these two job types differently when an environment is involved.
| Behavior | Regular job | deployment job |
|---|---|---|
| Respects environment approval checks | No | Yes |
| Inherits environment-scoped variables/secrets | No | Yes, at runtime |
| Appears in Environments dashboard history | No | Yes, per resource |
Supports lifecycle hooks (preDeploy, deploy) | No | Yes |
If your deploy stage uses a plain job block, the environment is just a string. The approval gate never triggers, and the Environments dashboard does not record the deployment.
A minimal progressive pipeline
Here is a three-stage pattern that gates production behind an environment approval. Approvals are configured in the Azure DevOps UI under Project settings → Pipelines → Environments, not in YAML. The YAML declares the target; the environment enforces the policy.
trigger:
branches:
include:
- main
variables:
- name: imageTag
value: $(Build.BuildId)
stages:
- stage: Build
jobs:
- job: BuildImage
pool:
vmImage: ubuntu-latest
steps:
- script: echo "Building $(imageTag)"
- stage: Deploy_Test
dependsOn: Build
condition: succeeded()
jobs:
- deployment: DeployToTest
environment: test
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to test namespace $(k8sNamespace)"
- stage: Deploy_Prod
dependsOn: Deploy_Test
condition: succeeded()
jobs:
- deployment: DeployToProd
environment: production
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to prod namespace $(k8sNamespace)"
In this example, Deploy_Test runs automatically after a successful build on main. Deploy_Prod targets the production environment. If you have configured a manual approval check on that environment for the Release Managers group, the pipeline pauses before the deployment job starts. The approval is recorded in the run history and the environment audit log.
Variables such as $(k8sNamespace) are defined per environment in the UI. The deployment job resolves them at runtime. A regular job would not see them.
The compile-time trap
Environment-scoped variables are injected when the job executes, not when the pipeline is compiled. You cannot use them in condition: expressions, template parameters, or ${{ }} compile-time expressions. This YAML will not behave as intended:
- stage: Deploy_Prod
condition: eq(variables['k8sNamespace'], 'prod')
Because k8sNamespace is an environment variable, it is not available when Azure DevOps evaluates the stage condition. Use pipeline-level variables or template parameters for branching logic, and reserve environment variables for runtime configuration inside scripts and deployment steps.
Verifying the gate and known limits
After a run, verify that the deployment registered correctly. Open Pipelines → Environments and select the environment. You should see the deployment job listed with its status, the pipeline run that triggered it, and the target resource.
For programmatic verification, query the approvals API. Run this from a terminal with Azure DevOps REST API access (a Personal Access Token with Build: Read and Release: Read scope):
curl -u :$PAT \
"https://dev.azure.com/{organization}/{project}/_apis/pipelines/runs/{runId}/approvals?api-version=7.1-preview.1"
Replace {organization}, {project}, {runId}, and $PAT with your values. The response lists pending and completed approvals for the run. If the production deployment job ran without an approval entry, the stage is likely using a regular job rather than a deployment job.
Plan around these limits:
- Cross-project environments are not supported. Environments are project-scoped. A multi-project deployment needs duplicate environment definitions or shared service connections.
- Approval timeouts default to 30 days. A forgotten approval can leave a pipeline waiting for a month. Set an explicit
timeoutInMinuteson the deployment job, or shorten the approval timeout in environment settings. - Manual approvals cannot be bypassed by script. Only designated approvers or Project Collection Administrators can approve or reject.
- Branch policies do not gate CD triggers. The "Require approval from designated approvers" branch policy applies to pull request validation runs, not to continuous deployment pushes on protected branches.
Actionable checklist
- Audit your deploy stages: confirm every stage that targets an environment uses
deployment:, notjob:. - Configure approval checks in the environment UI, not in YAML, and set a timeout shorter than 30 days.
- Move branching logic out of environment variables and into pipeline variables or template parameters.
- After the next production run, check the Environments dashboard and the approvals API response to confirm the gate fired.
Environments are more than naming conventions. Used with deployment jobs, they become the control plane for progressive delivery—provided you respect the line between build-time configuration and runtime deployment state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.