Updated Jun 30, 2026

Phase 3: Production Reality — Environments, Gates, and Secrets

You can now write a pipeline that builds, tests, and deploys. The moment it touches real servers, three new questions appear, and they're the ones that decide whether your pipeline is trustworthy. Where did this deploy go, and what's running there right now? Who gets to push the button that ships to production? And how do you give your deploy job a database password without writing it into a file the whole company can read? This phase answers all three, then walks through the failures that actually page people at 2am.

Environments: naming where you deploy

An environment is GitLab's record of a deploy target — staging, production, a per-branch review app. It's not infrastructure; it's a label plus a history. When a job declares environment:, GitLab tracks every deploy to that target: which commit, when, by whom, and a one-click way to see what's live and to roll back.

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

What just happened: this job deploys to staging and tells GitLab the live URL. In the Deployments → Environments view you now get a staging entry showing the current commit, a link straight to the running site, and a deployment history. The url becomes a clickable button — small thing, but it turns "is the deploy actually up?" from a Slack question into one click.

Manual gates: the button you have to press

Deploying to staging on every main push is fine. Deploying to production automatically is how teams ship a Friday-night outage. The fix is a manual job: it appears in the pipeline but waits, doing nothing, until a human clicks play.

deploy-production:
  stage: deploy
  script:
    - ./deploy.sh production
  environment:
    name: production
    url: https://example.com
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual

What just happened: on a main pipeline, deploy-production shows up as a play button instead of running. The pipeline can finish "successfully" with this job still pending — the deploy happens only when someone clicks it. That click is recorded against the production environment, so you have an audit trail of who shipped what.

A manual job is a gate, not a guarantee of safety. Anyone with the right project role can press it. For real protection on production, combine the manual gate with protected branches and protected environments in GitLab's project settings, which restrict who may run that deploy. The YAML expresses intent; the settings enforce it.

You can make the gate block the pipeline if you'd rather force a decision, by adding allow_failure: false — then the pipeline stays "blocked" until someone acts, instead of going green with the deploy still waiting.

Secrets: CI/CD variables

Your deploy job needs credentials — an API token, a database URL, an SSH key. These must never live in .gitlab-ci.yml, because that file is in the repo and visible to everyone with read access. Instead you store them as CI/CD variables in the project (or group) settings, and GitLab injects them into the job's environment at runtime.

deploy-production:
  stage: deploy
  script:
    - echo "Deploying with token..."
    - curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/release
  environment:
    name: production

What just happened: $DEPLOY_TOKEN isn't defined anywhere in the file. It's set in Settings → CI/CD → Variables, and the runner injects it as an environment variable when the job runs. The repo stays clean; the secret stays out of version control.

When you add a CI/CD variable, two checkboxes matter:

  • Masked — GitLab replaces the value with [masked] in job logs, so an accidental echo doesn't leak it. Turn this on for every secret. (The value must meet GitLab's masking rules — long enough, no problematic characters — or masking silently won't apply.)
  • Protected — the variable is exposed only to jobs running on protected branches or tags. This stops a feature branch from reading your production credentials. Turn it on for anything production-grade.
$ echo "Token is $DEPLOY_TOKEN"
Token is [masked]

What just happened: even though the script echoed the variable, masking replaced it in the log. Masking is a safety net, not permission to print secrets — but it saves you the day someone forgets.

When it breaks: the failures that page you

Real pipelines fail in a handful of recognizable ways. Knowing the shape saves you from staring at a red dot in confusion.

The job is stuck pending forever. No runner matches it. Either there are no runners available, or the job has a tags: value no runner advertises. Check the job — GitLab tells you "This job is stuck because there are no active runners." Fix the tag or the runner, don't blame the YAML.

The deploy "passed" but nothing changed. Classic masked-failure: a command in the middle of the script failed but a later command returned 0, so the job went green. By default the shell stops on the first failing command, but pipes and subshells can swallow errors. Make failures loud:

deploy:
  stage: deploy
  script:
    - set -euo pipefail
    - ./build.sh | tee build.log
    - ./deploy.sh production

What just happened: set -euo pipefail makes the shell exit on any failed command (-e), treat unset variables as errors (-u), and — crucially — fail if any command in a pipe fails (pipefail), not only the last one. Without it, ./build.sh | tee build.log would report success as long as tee succeeded, hiding a broken build.

A secret is empty in the job. Nearly always the variable is protected and the job is running on a non-protected branch, so GitLab refused to inject it. Either protect the branch or, for non-production targets, drop the protected flag. The log won't say "permission denied" — the variable is blank, so guard against it:

deploy:
  stage: deploy
  script:
    - test -n "$DEPLOY_TOKEN" || { echo "DEPLOY_TOKEN is empty — check protected branch settings"; exit 1; }
    - ./deploy.sh production

What just happened: the job fails fast with a clear message instead of making a credential-less API call that fails confusingly downstream. A two-line guard like this turns a 20-minute head-scratch into an instant diagnosis.

For builders: the muscle to build here is reading the job log first and the YAML second. CI failures feel mysterious because the pipeline is "out there," but the log is a complete, honest transcript of exactly what ran. If you came from GitHub Actions, the concepts map closely — same build/test/deploy shape, different file format and vocabulary; see /guides/your-first-pipeline-github-actions for the comparison.

[
  {
    "q": "How do you make a production deploy require a human to click before it runs?",
    "choices": [
      "Put it in its own stage",
      "Add when: manual to the job's rule (or as a top-level key)",
      "Mark its artifacts as protected",
      "Set expire_in: never"
    ],
    "answer": 1,
    "explain": "when: manual turns the job into a play button that waits for someone to trigger it, giving you a deliberate gate before production."
  },
  {
    "q": "Where should a production database password live so it's not in the repo?",
    "choices": [
      "Hardcoded in .gitlab-ci.yml under a comment",
      "In a CI/CD variable in project settings, marked masked and protected",
      "In an artifact that expires quickly",
      "In the cache, keyed on the branch"
    ],
    "answer": 1,
    "explain": "CI/CD variables keep secrets out of version control; masked hides them in logs and protected restricts them to protected branches."
  },
  {
    "q": "A deploy job shows green but the site didn't change. What's the most likely cause and a good guard?",
    "choices": [
      "The runner was too fast; add a sleep",
      "A mid-script command failed silently; add set -euo pipefail so failures stop the job",
      "The artifact expired; set expire_in longer",
      "The environment URL was wrong; remove it"
    ],
    "answer": 1,
    "explain": "Errors inside pipes or subshells can be swallowed so the job exits 0. set -euo pipefail makes any failed command fail the job loudly."
  }
]

← Phase 2: The Everyday Core | Overview

Check your understanding 3 questions

1. How do you make a production deploy require a human to click before it runs?

2. Where should a production database password live so it's not in the repo?

3. A deploy job shows green but the site didn't change. What's the most likely cause and a good guard?

Was this page helpful?