Skip to content
CloudOps
Newsletter
All guides
AI for GitLab CI/CD By James Joyner IV · · 10 min read

Deployment Approval Gates with GitLab Protected Environments

Manual jobs alone do not protect production. Here is how I build real approval gates with GitLab protected environments and audited deployment approvals.

  • #gitlab
  • #ci-cd
  • #deployments
  • #governance

Early on, my idea of a “production gate” was a manual job: when: manual, click the play button, deploy. It felt safe until I realized anyone with Developer access could click it, there was no record of who approved, and a fat-fingered click at 5pm went straight to prod with zero review. GitLab has a real answer for this — protected environments and deployment approvals — and once you combine them with the pipeline correctly, “who can ship to prod and who signed off” becomes an enforced policy, not a convention. Here’s how I wire it.

Why when: manual is not a gate

A manual job pauses the pipeline and waits for a click. That’s useful, but it controls timing, not authorization. Any user who can run pipelines can trigger it. There’s no second approver, no audit trail beyond “user X ran job Y.” For anything touching customers, you need authorization layered on top of timing.

Building the gate, step by step

Step one: define the environment in the job

Gates attach to environments, so your deploy job must declare one:

deploy-production:
  stage: deploy
  image: alpine/helm:3.15
  script:
    - helm upgrade --install api ./charts/api
        --set image.tag="$CI_COMMIT_SHA"
        --namespace production
  environment:
    name: production
    url: "https://app.example.com"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

The environment:name: production is the anchor everything else hangs from. Without it, protected-environment rules have nothing to bind to.

Step two: protect the environment

In Settings → CI/CD → Protected environments, mark production as protected and choose who is allowed to deploy — for example, Maintainers only, or a specific group. Now the manual button still appears, but only authorized users can actually run it. Developers see it grayed out. This alone closes the “anyone can click” hole.

Step three: require approvals

The bigger control is deployment approvals: require N approvers before a deployment to a protected environment can proceed. You configure the count in the protected-environment settings (Required approvals = 2, say). When a pipeline reaches the production deploy, it enters a pending approval state, and the configured approvers must sign off in the UI before it runs.

This gives you the audit trail I was missing: GitLab records who approved, when, and the deployment is blocked until the threshold is met. You can also configure approval rules so that the person who triggered the pipeline can’t self-approve — separation of duties, enforced.

Pro Tip: Approvals attach to the environment, not the job, so they apply no matter which pipeline or branch reaches that environment. That’s exactly what you want — you can’t sneak around the gate by deploying from a different pipeline.

Layering staging before production

A clean flow promotes through environments, each with its own protection level:

stages: [build, deploy-staging, deploy-prod]

deploy-staging:
  stage: deploy-staging
  script:
    - ./deploy.sh staging
  environment:
    name: staging
    url: "https://staging.example.com"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy-prod:
  stage: deploy-prod
  needs: ["deploy-staging"]
  script:
    - ./deploy.sh production
  environment:
    name: production
    url: "https://app.example.com"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

Staging deploys automatically; production requires the manual trigger and clears the protected-environment approval gate. The needs: ["deploy-staging"] ensures you can’t promote something that never reached staging.

Tracking deployments and rolling back

Because the job declares an environment, GitLab builds a deployment history per environment — every release, who triggered it, the commit. The environment page gives you a re-deploy button to roll back to a previous successful deployment, which is far safer at 3am than reconstructing a git revert by hand. Make sure your deploy script is idempotent so a re-deploy is genuinely a rollback and not a half-applied mess.

Adding an external check before deploy

Sometimes the gate is a machine, not a human — a change-management ticket must be approved, or a synthetic check must pass. You can model that as a job that queries the external system and fails closed:

change-gate:
  stage: deploy-prod
  image: curlimages/curl:8.8.0
  script:
    - |
      STATE=$(curl -s -H "Authorization: Bearer $CHANGE_API_TOKEN" \
        "https://change.example.com/api/change/$CI_PIPELINE_ID" | jq -r .state)
      if [ "$STATE" != "approved" ]; then echo "Change not approved"; exit 1; fi
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

The deploy-prod job then needs: ["change-gate"]. Notice $CHANGE_API_TOKEN is a masked, protected CI/CD variable — defined in project settings, never in the YAML, and never pasted into a chat window.

Scoping CI/CD variables to protected environments

Approval gates protect who can deploy, but there’s a sibling concern: which secrets a deploy can see. GitLab lets you mark a CI/CD variable as protected, meaning it’s only injected into pipelines on protected branches or tags. Combine that with environment-scoped variables and your production database password is unavailable to any job outside the production deploy path:

deploy-production:
  stage: deploy-prod
  environment:
    name: production
  script:
    - ./deploy.sh   # PROD_DB_PASSWORD only exists here, scoped to "production"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

In settings you scope PROD_DB_PASSWORD to the production environment and mark it protected. Now a feature-branch pipeline — or a malicious MR that tries to echo secrets — simply never receives the value. This is defense in depth: the protected environment controls authorization, and the scoped protected variable controls exposure. A leaked deploy credential is one of the worst CI incidents you can have, and scoping is the cheapest insurance against it.

The practical upshot for reviewers: when you see a deploy job, check that its secrets are both protected and environment-scoped. A production secret available to every branch is a finding, even if the approval gate is perfect. The gate stops the wrong deploy; scoping stops the wrong job from ever seeing the secret at all.

Where AI helps and the hard line on secrets

AI is the fast junior engineer here. It quickly drafts the multi-environment promotion YAML, the needs graph, and the external-gate script. It’s good at remembering environment:name is what protections bind to. But it consistently confuses manual job with protected environment — it’ll tell you when: manual is your approval gate when it absolutely is not. So I read every gate definition before merge and confirm the actual protection is configured in settings, which is the part that can’t live in YAML at all.

The unbreakable rule on this kind of pipeline: never hand AI your CI secrets. Not the kubeconfig, not CHANGE_API_TOKEN, not registry creds. If a gated deploy misbehaves, share the job YAML and the logs — never the credentials. When a gate fails mid-release and you’re triaging, our incident response dashboard gives you a structured path instead of frantic prompting, and the monitoring alerts dashboard helps you confirm the deploy actually stayed healthy after it cleared the gate.

My reusable prompt: “Draft a GitLab CI flow that auto-deploys to staging and requires a manual, approval-gated deploy to production. Use environment: blocks and needs:, and add a note about what I must configure in protected-environment settings that YAML alone can’t enforce.” That last clause makes the model surface the settings step. More variants are in my prompt library and the deployment prompt packs.

Conclusion

A manual job controls timing; protected environments and deployment approvals control authorization — and you need both for a real gate. Declare the environment, protect it, require approvers, and layer staging before prod. Let AI draft the YAML fast, then verify the actual protections in settings and keep every secret out of the chat. Explore more in the GitLab CI/CD category.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.