BYOWBuild Your Own Workflow

Part 2c · GitLab CI/CD

A secure pipeline on GitLab CI/CD

Protect branches, variables and runners, add GitLab's built-in scanners, enforce them with security policies, then sign the image with Sigstore keyless signing and deploy to Azure with an OIDC ID token through a protected environment.

~40 min GitLab.com or self-managed Dependency-Scanning.v2 id_tokens Tier noted per feature

What you will build

The same Node.js service, contoso/payments-api, built into the project's own container registry and deployed to Azure Container Apps. GitLab puts security scanning, the vulnerability report and policy enforcement in one product, so much of this tutorial is configuration rather than YAML.

contoso/payments-api                # application project
├── .gitlab-ci.yml                  # build → test+scan → sign → verify → deploy
├── CODEOWNERS
├── Dockerfile
└── src/

contoso/security-policies           # security policy project, owned by AppSec
└── .gitlab/security-policies/
    └── policy.yml                  # scan execution + merge request approval policies

Features differ by tier, so each one below is marked Free, Premium or Ultimate. The pipeline itself works on every tier; Ultimate adds the vulnerability report, dependency scanning and policies.

Step 1Protect branches, variables and runners

Protected branch main Free Settings›Repository›Protected branches Allowed to merge: Maintainers. Allowed to push and merge: No one. Force push: off. Turn on Code owner approval (Premium) so changes to .gitlab-ci.yml need the owners listed in CODEOWNERS.
Merge request approvals and checks Premium Settings›Merge requests Require at least one approval; prevent approval by the author and by users who add commits; remove all approvals when a commit is added. Under merge checks, require that pipelines succeed and all threads are resolved.
Secret push protection Ultimate Secure›Security configuration Blocks pushes that contain high-confidence secrets before they reach the repository. On lower tiers, the Secret Detection job in step 3 finds them after the push.
Protected, masked and hidden variables Free Settings›CI/CD›Variables A protected variable is only available to pipelines on protected branches and tags, so a developer's feature branch cannot print it. Mask and hide every secret, and scope deploy variables to the production environment.
Restrict pipeline variables Free Settings›CI/CD›Variables Set the minimum role allowed to override variables when running a pipeline to Maintainer, or to "No one allowed". Overridable variables are an injection path into every job.
Protected runners for trusted jobs Free Settings›CI/CD›Runners Register the privileged runner used for Docker builds as "Protected" with a tag such as protected-docker. Protected runners only pick up jobs from protected refs, so merge request code never runs on them. Use autoscaling runners that create a fresh VM or pod per job.
Protected environment with approvals Premium Settings›CI/CD›Protected environments Protect production: only the release managers group can deploy, and at least one approval is required. A deployment job waits until it is approved.
CODEOWNERStext
[Pipeline][1]
/.gitlab-ci.yml        @contoso/platform-security
/.gitlab/              @contoso/platform-security
/.trivyignore.yaml     @contoso/platform-security

Step 2Job tokens and OIDC

Keep the job token allowlist closed

Every job gets CI_JOB_TOKEN, which can pull from registries and call some APIs with the permissions of the user who started the pipeline. The CI/CD job token allowlist under Settings›CI/CD›Job token permissions decides which other projects' jobs may use their token against this project. By default only the project itself is on the list. Add other projects only when you must, and use fine-grained job token permissions to limit the API endpoints they can reach.

OIDC with id_tokens

A job declares the ID tokens it needs and the audience of each. GitLab signs a JWT and exposes it as a variable for that job only. Use one token per destination, so a token issued for Sigstore cannot be replayed against Azure.

id_tokens in a jobyaml
deploy-production:
  id_tokens:
    AZURE_ID_TOKEN:
      aud: api://AzureADTokenExchange     # Entra ID expects this audience
  script:
    - az login --service-principal
        --username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID"
        --federated-token "$AZURE_ID_TOKEN"

In Entra ID, add a federated credential to the deploy identity:

FieldValue
Issuerhttps://gitlab.com (or your instance URL)
Subjectproject_path:contoso/payments-api:ref_type:branch:ref:main
Audienceapi://AzureADTokenExchange

The default subject names the project and branch. Because main is protected, only reviewed code can obtain the token, and the protected environment adds the approval. The token also carries environment and environment_protected claims, which Vault and AWS trust policies can check directly. For HashiCorp Vault, the secrets: keyword uses the same ID tokens to fetch secrets at job start.

Watch out

The old predefined CI_JOB_JWT variables are gone. If a guide tells you to use them, it predates id_tokens; a deprecated token is a common cause of 401 errors from cloud providers.

Step 3Built-in scanners

GitLab ships its scanners as CI templates. Including them adds jobs to the test stage that write reports GitLab understands. Dependency scanning now uses the SBOM-based analyzer in the v2 template; the Gemnasium analyzer is deprecated.

.gitlab-ci.yml · includeyaml
include:
  - template: Jobs/SAST.gitlab-ci.yml                     # Free
  - template: Jobs/Secret-Detection.gitlab-ci.yml         # Free
  - template: Jobs/SAST-IaC.gitlab-ci.yml                 # Free
  - template: Jobs/Container-Scanning.gitlab-ci.yml       # Free
  - template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml   # Ultimate

variables:
  CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA             # what container scanning scans
  SAST_EXCLUDED_PATHS: "spec, test, tests, tmp, node_modules"
TemplateFindsReport
SASTCode vulnerabilities, using Semgrep-based analyzers (GitLab Advanced SAST on Ultimate)sast
Secret DetectionSecrets in the latest commitssecret_detection
SAST-IaCTerraform, Kubernetes, Dockerfile and cloud template misconfigurationssast
Container ScanningOS and language packages in the image, plus a CycloneDX SBOMcontainer_scanning
Dependency Scanning v2Vulnerable dependencies from lockfiles, plus a CycloneDX SBOMdependency_scanning

These jobs report; they do not block. The template jobs succeed when they find vulnerabilities. Blocking happens in one of two places: a merge request approval policy (step 4, Ultimate) or blocking jobs you add yourself. The pipeline in this tutorial adds one blocking job on every tier, an image gate that fails on fixable critical and high CVEs before anything is signed:

.gitlab-ci.yml · image gateyaml
image-gate:
  stage: test
  image:
    name: aquasec/trivy:0.74.0
    entrypoint: [""]
  variables:
    TRIVY_USERNAME: $CI_REGISTRY_USER
    TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1
        "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"

Blocking gates on Free and Premium

Without approval policies, add open-source scanners as jobs that exit non-zero on findings. Mark them required by keeping "Pipelines must succeed" on.

.gitlab-ci.yml · open-source blocking jobsyaml
secrets-gate:
  stage: test
  image:
    name: ghcr.io/gitleaks/gitleaks:v8.30.1
    entrypoint: [""]
  script:
    - gitleaks dir . --redact

sast-gate:
  stage: test
  image: semgrep/semgrep:1.179.0
  script:
    - semgrep scan --config p/default --severity ERROR --error

sca-gate:
  stage: test
  image:
    name: aquasec/trivy:0.74.0
    entrypoint: [""]
  script:
    - trivy fs --scanners vuln --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 .

Step 4Enforce with security policies Ultimate

A template in .gitlab-ci.yml can be deleted by anyone who can edit that file. Security policies live in a separate project owned by your security team and apply to every project linked to it. Two kinds matter here.

Scan execution policy

Scans always run

Injects the chosen scanners into every pipeline of the linked projects, even if the project's own CI file does not include them.

Merge request approval policy

New risk needs sign-off

When a merge request would introduce findings above a threshold, it requires approval from the security group before it can merge. This is the merge gate.

contoso/security-policies · .gitlab/security-policies/policy.ymlyaml
scan_execution_policy:
  - name: Baseline scans on every pipeline
    description: Runs even if a project removes the templates
    enabled: true
    rules:
      - type: pipeline
        branch_type: all
    actions:
      - scan: secret_detection
      - scan: sast
      - scan: sast_iac
      - scan: dependency_scanning

approval_policy:
  - name: Block new critical and high vulnerabilities
    description: AppSec approves any MR that introduces them
    enabled: true
    rules:
      - type: scan_finding
        branch_type: protected
        scanners: [sast, secret_detection, dependency_scanning, container_scanning]
        vulnerabilities_allowed: 0
        severity_levels: [critical, high]
        vulnerability_states: [new_needs_triage, new_dismissed]
    actions:
      - type: require_approval
        approvals_required: 1
        group_approvers: [contoso/appsec]
      - type: send_bot_message
        enabled: true

Including new_dismissed in the vulnerability states means a developer cannot dismiss a new finding to get around the approval. For jobs that must run in every pipeline but are not scanners, such as a license check or an audit step, use a pipeline execution policy, the successor to compliance pipelines.

Step 5Build and sign with Sigstore

The image is built only on main, on the protected privileged runner. The digest is passed to later jobs through a dotenv report, so every later job works with the immutable digest rather than a tag.

.gitlab-ci.yml · build-imageyaml
build-image:
  stage: build
  image: docker:28.3
  services:
    - docker:28.3-dind
  tags: [protected-docker]               # only a protected runner may run privileged builds
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
    - digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" | cut -d@ -f2)
    - echo "IMAGE_DIGEST=$digest" >> build.env
  artifacts:
    reports:
      dotenv: build.env

Signing uses Sigstore keyless signing. The job requests an ID token with the audience sigstore; cosign reads it from SIGSTORE_ID_TOKEN, gets a short-lived certificate that names this project, branch and CI file, and records the signature in the public Rekor transparency log. There is no signing key to store or rotate.

.gitlab-ci.yml · sign-imageyaml
sign-image:
  stage: sign
  image: alpine:3.22
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  script:
    - apk add --no-cache cosign
    - echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
    - cosign sign --yes "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"

Self-managed GitLab

The public Sigstore certificate authority accepts ID tokens from GitLab.com. On a self-managed instance, sign with a key in a cloud KMS instead (cosign sign --key with a KMS URI) or run your own Sigstore stack.

Step 6Verify and deploy

Verification checks that the signature's certificate was issued to this project's CI file on main. An image signed by any other project or branch fails. Because the digest is immutable, verifying in one job and deploying the same digest in the next is safe.

.gitlab-ci.yml · verify and deployyaml
verify-signature:
  stage: verify
  image: alpine:3.22
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - apk add --no-cache cosign
    - echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
    - cosign verify "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
        --certificate-identity "https://gitlab.com/$CI_PROJECT_PATH//.gitlab-ci.yml@refs/heads/$CI_DEFAULT_BRANCH"
        --certificate-oidc-issuer "https://gitlab.com"

deploy-production:
  stage: deploy
  image: mcr.microsoft.com/azure-cli:2.70.0
  interruptible: false
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  environment:
    name: production                     # protected environment: approval required
    deployment_tier: production
  resource_group: production             # one deployment at a time
  id_tokens:
    AZURE_ID_TOKEN:
      aud: api://AzureADTokenExchange
  script:
    - az login --service-principal
        --username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID"
        --federated-token "$AZURE_ID_TOKEN"
    - az containerapp update
        --name payments-api --resource-group rg-payments-prod
        --image "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
  • Stage order is the gate. These jobs do not use needs, so each stage waits for every job in the previous one. A needs list lets a job skip ahead of jobs it does not list, which can silently bypass the image gate.
  • resource_group makes concurrent pipelines queue their deployments instead of racing.
  • Environment-scoped variables. Scope AZURE_CLIENT_ID and AZURE_TENANT_ID to production and mark them protected, so only this job sees them.

Pitfalls that get pipelines owned

Re-parsing untrusted values

The shell expands $CI_MERGE_REQUEST_TITLE or $CI_COMMIT_MESSAGE safely when you quote it as an argument. It becomes code when it ends up inside a string that is parsed again, such as sh -c, eval or a generated script.

Injectableyaml
script:
  - sh -c "./notify.sh $CI_MERGE_REQUEST_TITLE"
Safeyaml
script:
  - ./notify.sh "$CI_MERGE_REQUEST_TITLE"

Unprotected secrets

A variable that is not protected is available on every branch. Any developer can push a branch whose pipeline prints it. Protect every secret and scope it to an environment.

Fork merge requests in the parent project

Pipelines for merge requests from forks run in the fork by default, without the parent's variables. Running them in the parent project gives fork code access to parent resources; do it only after reviewing the change.

Unpinned includes

include: project: without ref: follows the other project's default branch, and include: remote: fetches whatever the URL serves at run time. Pin includes to a tag or commit SHA, and pin CI/CD catalog components to an exact version rather than ~latest.

Debug logging

CI_DEBUG_TRACE prints every variable the job can see and can reveal values that are normally masked. Never enable it on jobs that hold secrets.

Complete files

.gitlab-ci.ymlyaml
stages: [build, test, sign, verify, deploy]

include:
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml
  - template: Jobs/SAST-IaC.gitlab-ci.yml
  - template: Jobs/Container-Scanning.gitlab-ci.yml
  - template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml   # Ultimate

variables:
  CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  SAST_EXCLUDED_PATHS: "spec, test, tests, tmp, node_modules"

default:
  interruptible: true
  timeout: 30m

build-image:
  stage: build
  image: docker:28.3
  services:
    - docker:28.3-dind
  tags: [protected-docker]
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
    - digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" | cut -d@ -f2)
    - echo "IMAGE_DIGEST=$digest" >> build.env
  artifacts:
    reports:
      dotenv: build.env

unit-tests:
  stage: test
  image: node:22-alpine                  # pin by digest in production
  script:
    - npm ci --ignore-scripts
    - npm test

container_scanning:                      # template job: scan only the image built on main
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

image-gate:
  stage: test
  image:
    name: aquasec/trivy:0.74.0
    entrypoint: [""]
  variables:
    TRIVY_USERNAME: $CI_REGISTRY_USER
    TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1
        "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"

sign-image:
  stage: sign
  image: alpine:3.22
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  script:
    - apk add --no-cache cosign
    - echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
    - cosign sign --yes "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"

verify-signature:
  stage: verify
  image: alpine:3.22
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - apk add --no-cache cosign
    - echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
    - cosign verify "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
        --certificate-identity "https://gitlab.com/$CI_PROJECT_PATH//.gitlab-ci.yml@refs/heads/$CI_DEFAULT_BRANCH"
        --certificate-oidc-issuer "https://gitlab.com"

deploy-production:
  stage: deploy
  image: mcr.microsoft.com/azure-cli:2.70.0
  interruptible: false
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  environment:
    name: production
    deployment_tier: production
  resource_group: production
  id_tokens:
    AZURE_ID_TOKEN:
      aud: api://AzureADTokenExchange
  script:
    - az login --service-principal
        --username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID"
        --federated-token "$AZURE_ID_TOKEN"
    - az containerapp update
        --name payments-api --resource-group rg-payments-prod
        --image "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"

Multi-line commands

Script lines that continue on the next, more-indented line are YAML plain-scalar continuation: GitLab joins them with spaces into one command. It keeps long commands readable without backslashes.

References

Next: compare the three platforms and work through the hardening checklist.