Sachin Chaurasiya

CI/CD Part 2 of 3 · DevSecOps Pipeline

A GitLab CI/CD Security Pipeline That Developers Do Not Route Around

The GitLab pipeline that gated this site while it ran CI: frozen installs, parallel quality gates, Trivy with SAST and secret detection, artifact verification, and one deployment owner behind protected main.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
20 min read
Difficulty
intermediate

Reviewed Tested with GitLab.com shared runners, Node 22, pnpm 12.4, Trivy 0.65

On this page

What this pipeline has to do

The pipeline described here gated every change to this website until September 2026, so every job in it has run on GitLab.com shared runners against this repository. The site has since moved to a local validation flow: the same gates run as pnpm validate before a maintainer merges into main, Cloudflare Workers Builds stays the only deployer, and GitLab runs no pipeline at all. The YAML remains in the repository’s history as an executed reference. It is small (twelve jobs, under two minutes end to end), but it makes the decisions that matter for a security pipeline of any size:

  • Dependencies install from a frozen lockfile, and dependency build scripts are allow-listed, so a compromised package cannot run code at install time without a reviewed change.
  • Quality and security gates run in parallel on the merge request, not in a serial chain that makes developers wait for lint before they learn about a CVE.
  • The artifact that is verified is the artifact that is deployed. Nothing is rebuilt between the last check and production.
  • Production has exactly one deployer. For this site it is Cloudflare Workers Builds, which builds main after the merge with a token that never enters GitLab; the pipeline itself deploys nothing. The second half of the article shows the other valid shape, a deploy job that only exists on the protected branch, and why you must pick one of the two and never run both.

If you take one thing from this article, take the last point. Most pipeline security incidents are not exotic; they are a deploy token that was available to every branch, or two systems deploying the same commit in different ways.

Architecture

Diagram · GitLab CI pipeline with security gates
GitLab CI pipeline with security gatesA merge request triggers a pipeline: install, then quality checks (format, lint, type check) in parallel, then a build that stores the site as an artifact. Trivy, Semgrep SAST and secret detection run on the source and lockfile. Post-build checks verify links, SEO metadata and security headers in the artifact. Only a merge to the protected main branch reaches production: Cloudflare Workers Builds, connected to the repository, clones the merge commit, builds it and deploys it to Cloudflare Workers with a token that never enters GitLab.Merge request pipelineProduction (Cloudflare)pushsourcedist/all greenpush eventwranglerMerge requestfeature/* → mainInstallfrozen lockfile1Qualityformat · lint · check2Buildartifact: dist/3Securitytrivy · sast · leaks4Verify artifactlinks · seo · headers5Protected mainmerge after green6Workers Buildsbuild · deploy7CloudflareWorkersstatic assets

A merge request triggers a pipeline: install, then quality checks (format, lint, type check) in parallel, then a build that stores the site as an artifact. Trivy, Semgrep SAST and secret detection run on the source and lockfile. Post-build checks verify links, SEO metadata and security headers in the artifact. Only a merge to the protected main branch reaches production: Cloudflare Workers Builds, connected to the repository, clones the merge commit, builds it and deploys it to Cloudflare Workers with a token that never enters GitLab.

  1. Install runs once per pipeline with pnpm install --frozen-lockfile. The pnpm store is cached by lockfile hash, so later jobs restore it in seconds. A lockfile change is the only way to change what gets installed.
  2. Quality is three jobs in parallel: Prettier, ESLint and astro check (TypeScript plus template validation). Each is a gate; none depends on another.
  3. Build produces dist/ (HTML, hashed assets, the search index, _headers) and stores it as a pipeline artifact with a one-week expiry.
  4. Security runs on the source tree rather than the artifact: Trivy scans the lockfile for vulnerable dependencies and the repository for misconfigurations and secrets; GitLab’s SAST template runs Semgrep; GitLab’s Secret Detection template runs Gitleaks against the commits in the merge request. These jobs need only the install stage, so they start while the build is still running.
  5. Verify takes the built artifact and checks that every internal link resolves, every page has valid SEO metadata, and the security headers file plus the per-page CSP are present. It fails if unsafe-eval ever appears in a policy.
  6. Protected main is the trust boundary. Merge requests can only be merged when the pipeline is green, nobody can push directly, and force-push is disabled.
  7. Workers Builds is where production begins. Cloudflare’s Git integration is connected to the repository with main as its production branch: the merge commit triggers a build that installs from the same frozen lockfile, runs pnpm build, and deploys with wrangler deploy. The credential is generated and held by Cloudflare. A push to any other branch produces a preview version and a comment on the merge request, never a production change.

The pipeline therefore holds no secrets at all. That is the point of the split: GitLab validates, Cloudflare deploys, and the only thing that connects them is a protected branch that nobody can push to directly.

Prerequisites

  • A GitLab project on gitlab.com (Free tier is enough for everything shown) with shared runners enabled
  • A Node project using pnpm; the same shape works for npm or yarn with the install line changed
  • Maintainer access to configure protected branches and CI/CD variables
  • A deploy target: here Cloudflare Workers Builds connected to the project; for the CI-as-deployer variant, an API token for the target

Implementation

Stages and pipeline rules

stages:
  - install
  - quality
  - build
  # `test` exists only so the GitLab SAST/Secret-Detection templates validate;
  # their jobs are moved to `security` below.
  - test
  - security
  - verify

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG
    - if: $CI_PIPELINE_SOURCE == "schedule"
    - if: $CI_PIPELINE_SOURCE == "web"

default:
  image: node:22-bookworm-slim
  interruptible: true

variables:
  PNPM_VERSION: '12.4.1'
  PNPM_HOME: '$CI_PROJECT_DIR/.pnpm-home'
  GIT_DEPTH: '20'
  CI: 'true'

include:
  - local: .gitlab/ci/quality.yml
  - local: .gitlab/ci/security.yml
  - local: .gitlab/ci/verify.yml
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml

Three details here are easy to get wrong:

  • The workflow.rules block means a push to a feature branch without a merge request does not start a pipeline. Pipelines run for merge requests, the default branch, tags, schedules and manual web runs. This halves runner minutes and, more importantly, means every pipeline that matters is attached to a review.
  • interruptible: true in default lets GitLab cancel a running pipeline when a newer commit arrives on the same merge request. Nothing here deploys, so nothing needs to opt out; a deploy job, if you add one, must override this to false, because a deploy must never be cancelled halfway.
  • The GitLab security templates hard-code stage: test. If your pipeline has no test stage the configuration is rejected at validation time, so the stage is declared even though nothing runs in it. The template jobs are then re-homed into security by redefining them with the same name.

One install, cached by lockfile

.node:
  before_script:
    - node --version
    - npm install -g "pnpm@${PNPM_VERSION}" --no-fund --no-audit >/dev/null
    - pnpm config set store-dir "$CI_PROJECT_DIR/.pnpm-store"
    - pnpm install --frozen-lockfile --prefer-offline
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store
    policy: pull

install:
  stage: install
  extends: .node
  script:
    - pnpm --version
    - pnpm ls --depth 0 | head -50
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store
    policy: pull-push

Every later job extends .node, so each one runs pnpm install --frozen-lockfile from the cached store. The install job is the only one allowed to push the cache; everything else pulls. That avoids a race where two parallel jobs upload slightly different stores.

--frozen-lockfile is the supply chain control. If package.json and pnpm-lock.yaml disagree, the install fails instead of silently resolving a new version. Combined with pnpm 12’s default of not running dependency build scripts unless they are listed in pnpm-workspace.yaml, a malicious postinstall hook in a transitive dependency has no path to execution on the runner.

Node itself is pinned to a major (node:22-bookworm-slim); the runner image digest changes with patch releases, which is acceptable for a build image and is exactly what Renovate exists to keep up with.

Quality gates in parallel

format:
  stage: quality
  extends: .node
  needs: [install]
  script:
    - pnpm format:check

lint:
  stage: quality
  extends: .node
  needs: [install]
  script:
    - pnpm lint

typecheck:
  stage: quality
  extends: .node
  needs: [install]
  script:
    - pnpm typecheck

needs: [install] turns the stage list into a directed graph. GitLab starts all three the moment install finishes, and the pipeline view shows them side by side. A failed format job does not hide a failed typecheck; the developer sees every problem in one run.

Build once, keep the artifact

build:
  stage: build
  extends: .node
  needs: [install]
  variables:
    PUBLIC_CF_BEACON_TOKEN: $PUBLIC_CF_BEACON_TOKEN
  script:
    - pnpm build
    - du -sh dist
    - test -f dist/index.html && test -f dist/404.html && test -f dist/_headers
  artifacts:
    name: 'site-$CI_COMMIT_SHORT_SHA'
    paths:
      - dist/
    expire_in: 1 week

The test -f line is a cheap sanity check that has caught a mis-configured output directory before the more expensive verification jobs ran. Everything downstream (links, seo, headers) declares needs: [build] and receives this exact dist/ directory. There is no second build inside the pipeline.

PUBLIC_CF_BEACON_TOKEN is the analytics site token, which is a public value by design (it ships in the HTML). It is listed here to make the point that even non-secret configuration flows through CI/CD variables rather than being committed.

Security jobs

trivy:
  stage: security
  needs: [install]
  image:
    name: aquasec/trivy:0.65.0
    entrypoint: ['']
  variables:
    TRIVY_CACHE_DIR: .trivycache
    TRIVY_NO_PROGRESS: 'true'
  cache:
    key: trivy-db
    paths:
      - .trivycache/
  script:
    - trivy --version
    # Informational report (all severities) kept as an artifact.
    - trivy fs --scanners vuln,misconfig,secret --format table --exit-code 0 --output trivy-report.txt .
    # Gate: fixable HIGH/CRITICAL only.
    - trivy fs --scanners vuln,misconfig,secret --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 .
  artifacts:
    when: always
    paths:
      - trivy-report.txt
    expire_in: 1 week

semgrep-sast:
  stage: security
  needs: []
  interruptible: true

secret_detection:
  stage: security
  needs: []
  interruptible: true
  variables:
    GIT_DEPTH: '0'

trivy:scheduled:
  stage: security
  extends: trivy
  needs: []
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"

Trivy runs twice on purpose. The first invocation writes a full report at every severity and never fails; the second is the gate and only fails on HIGH or CRITICAL findings that have a fix. A finding with no fixed version cannot be resolved by the team that owns the pipeline, and a gate that fails on it teaches people to click “retry” without reading. Unfixed findings stay visible in the report artifact. (Version 0.65.0 is what the pipeline pins today; the same flags work on 0.68, which is what the local verification for this article used.)

trivy fs with --scanners vuln,misconfig,secret covers three different classes of problem in one job: vulnerable packages in pnpm-lock.yaml, misconfiguration in Dockerfiles and IaC (this repository has none, so the scanner reports nothing), and secrets committed to the tree. It scans the checkout, not the built site, so it runs as soon as install is done.

The two GitLab template jobs (semgrep-sast and secret_detection) are redefined only to move them into the security stage, mark them interruptible and, for secret detection, request a full clone. Secret Detection on a merge request scans the commits that are new to the MR; on the default branch it scans the full history, which needs GIT_DEPTH: '0'. Both attach their findings to the merge request widget on the Free tier, which is why the templates are worth using even when you also run the same tools yourself elsewhere.

trivy:scheduled is the same job under a different name, run by a nightly scheduled pipeline. Its purpose is not the code (nothing changed) but the database: a dependency that was clean on Monday can have a CVE by Wednesday, and without a schedule nobody finds out until the next merge request.

Verify the artifact

links:
  stage: verify
  extends: .node
  needs: [build]
  script:
    - node scripts/check-links.mjs dist

seo:
  stage: verify
  extends: .node
  needs: [build]
  script:
    - node scripts/check-seo.mjs dist

headers:
  stage: verify
  image: busybox:1.36
  needs: [build]
  before_script: []
  cache: []
  script:
    - grep -q "Strict-Transport-Security" dist/_headers
    - grep -q "frame-ancestors 'none'" dist/_headers
    - grep -q 'http-equiv="content-security-policy"' dist/index.html
    - '! grep -rIl --include=''*.html'' "unsafe-eval" dist | grep -v wasm-unsafe-eval || (echo ''unsafe-eval found in CSP'' && exit 1)'

These three jobs are the difference between “the build succeeded” and “the thing we are about to deploy is correct”. The link checker walks every HTML file and confirms that each internal href, src and anchor resolves to a built file. The SEO checker asserts one title, one description, one canonical URL and one h1 per page and cross-checks the sitemap. The header job is deliberately tiny and runs in busybox with no dependencies: it greps for the security headers and refuses the artifact if a Content Security Policy containing unsafe-eval ever slips in.

Notice before_script: [] and cache: [] on the headers job. Without them the job would inherit the .node defaults and spend forty seconds installing Node dependencies to run grep.

The deploy jobs, if CI is the deployer

This site does not run the two jobs below any more: production moved to Cloudflare Workers Builds, and the deploy stage was removed from the pipeline. They are kept here because they are the right shape when your CI system is the deployer, which is the common case with a container platform or a cloud provider that has no Git integration. The YAML was part of this repository’s pipeline and validated by GitLab, but it was never executed against production here, so read it as a reviewed template rather than a tested run.

deploy:preview:
  stage: deploy
  extends: .node
  needs: [build, links, seo, headers]
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CLOUDFLARE_API_TOKEN
      when: manual
      allow_failure: true
  environment:
    name: preview/$CI_MERGE_REQUEST_IID
    action: prepare
  script:
    - pnpm exec wrangler versions upload --message "MR !$CI_MERGE_REQUEST_IID $CI_COMMIT_SHORT_SHA"

deploy:production:
  stage: deploy
  extends: .node
  needs: [build, links, seo, headers, trivy]
  resource_group: production
  interruptible: false
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CLOUDFLARE_API_TOKEN
  environment:
    name: production
    url: https://sachinchaurasiya.com
    deployment_tier: production
  script:
    - pnpm exec wrangler --version
    - pnpm exec wrangler deploy

Read the rules on deploy:production carefully. It runs only when the commit is on the default branch and the token variable is non-empty. Because the token is a protected variable, it is empty on every non-protected branch, so even a misconfigured rule would produce a job with no credential rather than a deploy from a feature branch. The two conditions back each other up.

resource_group: production gives the job a lock: if two merge requests land a minute apart, the second deploy waits for the first to finish rather than racing it. interruptible: false prevents a newer pipeline from cancelling a deploy in progress.

deploy:preview uses wrangler versions upload, which publishes a new Worker version with a preview URL without changing production traffic. It is manual and non-blocking, so a reviewer can click it to see a visual change and ignore it otherwise.

The platform as deployer

The alternative, and what this site uses, is to let the hosting platform build and deploy from the protected branch. Cloudflare Workers Builds is connected to the GitLab project, watches main, and on every push event clones the commit, runs pnpm build and pnpm run deploy. Its API token is generated by Cloudflare and never appears in GitLab; nothing in the pipeline can deploy, because nothing in the pipeline holds a credential.

What the platform cannot do is gate the merge. While this site ran the pipeline above, it decided whether a change could land on main, through “Pipelines must succeed” and a branch nobody could push to. The trade was that the build happened twice, once in GitLab for verification and once in Cloudflare for deployment, from the same commit and the same lockfile. In exchange there was no long-lived deployment token to protect, rotate or leak, and no way for a pipeline change to widen who could deploy.

This site has since dropped the pipeline and runs the same gates locally as pnpm validate before a maintainer merges into main. GitLab keeps no CI configuration, no runners and no variables, and Cloudflare still runs only the build. That removes a class of CI-side risk (nothing in the project can execute code on a runner) at the price of server-side enforcement: the checks pass because the maintainer ran them, not because the platform refused the merge. For a single-maintainer site that trade is defensible; for a team, keep the pipeline and let the branch protection above do the enforcing.

Configuration outside the YAML

Pipeline YAML enforces nothing on its own. These project settings make the branch protection real:

SettingValueWhy
Repository → Protected branches → mainAllowed to push: No one; allowed to merge: Maintainers; no force pushEvery change to main is a merge request with a pipeline
Merge requests → Merge checksPipelines must succeed; all threads resolvedA red pipeline cannot be merged by habit
CI/CD → VariablesNoneThe pipeline deploys nothing, so it needs no credential
CI/CD → SchedulesNightly, on mainRuns trivy:scheduled against a fresh vulnerability database
Cloudflare → Workers BuildsRepository connected, production branch main, build pnpm buildThe only deployer; it can only see commits that passed the gate

If your CI is the deployer, add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as masked, protected variables and enable “Prevent outdated deployment jobs”. The token then follows the same least-privilege rule as the pipeline: created from Cloudflare’s “Edit Cloudflare Workers” template, scoped to one account and one zone, and unable to change DNS, WAF rules or anything else.

Verification

A green run of this pipeline on a merge request looks like this in the GitLab UI (job names, grouped by stage):

install     install
quality     format · lint · typecheck
build       build
security    secret_detection · semgrep-sast · trivy
verify      headers · links · seo

Twelve jobs (the test stage adds unit tests and content checks), 1 minute 46 seconds wall-clock on gitlab.com shared runners at the time of writing, with quality and security overlapping. There is no deploy stage: the pipeline on main is identical to the one on the merge request, and the deployment is visible on the Cloudflare side as a Workers Build for the merge commit.

To prove the gates bite rather than decorate, break each one on a throwaway branch:

# 1. Quality gate: a formatting error should fail `format` and nothing else
printf 'const x  =  1\n' >> src/utils/format.ts && git commit -am "test: break format" && git push

# 2. Supply chain gate: an out-of-band lockfile edit must fail `install`
sed -i.bak 's/^lockfileVersion.*/lockfileVersion: 0/' pnpm-lock.yaml && git commit -am "test: break lockfile" && git push

# 3. Security gate: a token with the real shape and random content must fail `secret_detection`
printf 'GITLAB_TOKEN=glpat-%s\n' "$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20)" > .env.leak \
  && git add .env.leak && git commit -m "test: break secrets" && git push

Each push should produce exactly one red job in the expected stage while the other jobs still run and report. Do not use Amazon’s documented example keys for the third test: Gitleaks allow-lists them, so they prove nothing. Delete the branch when done; even a fake token is noise for anyone auditing history later.

For the Trivy gate, the honest test is a real vulnerable version: pin a dependency to a release with a known fixable CVE in package.json, run pnpm install locally to update the lockfile, and push. trivy should exit 1 and the report artifact should name the package, the CVE and the fixed version.

Security implications

  • No secrets on merge requests. This pipeline holds none at all. If yours deploys, protected variables are the mechanism and the deploy rule is the second layer; a contributor who opens a merge request from a fork must get a pipeline with no credentials in it.
  • Logs are not a leak. Masked variables are replaced with [MASKED] in job output. Trivy’s secret scanner prints the rule and location rather than the value, and if you run Gitleaks directly instead of through the template, pass --redact so the job log does not become the second copy of the secret.
  • Artifacts are retention-bounded. dist/ and trivy-report.txt expire after a week. Artifacts are downloadable by anyone with Reporter access to the project, so nothing sensitive is written into them.
  • The runner is a shared environment. On gitlab.com shared runners each job is a fresh container, but the cache is not: the pnpm store is restored from previous pipelines. Keying the cache by the lockfile hash means a poisoned cache would require a lockfile change that shows up in review.
  • What happens on failure is defined. A failed gate blocks the merge; a failed deploy leaves the previous Worker version serving traffic, because wrangler deploy is atomic per version. Rollback is a git revert and a normal pipeline, not a manual upload.

Troubleshooting

SymptomCauseFix
stage test is not defined at pipeline creationThe SAST / Secret Detection templates assign stage: testDeclare a test stage even if empty, then re-home the jobs into your own stage
format fails on files under .pnpm-store/ or .trivycache/Runner caches are restored into the project directory and Prettier walks themAdd the cache directories to .prettierignore and the ESLint ignores list
Every job reinstalls dependencies for a minuteCache key changed, or a job pushes a cache the others cannot useKey on pnpm-lock.yaml; only install uses policy: pull-push
secret_detection finds nothing on a merge request with a known leakShallow clone; the commit range could not be computedSet GIT_DEPTH: '0' on the job
A pipeline is cancelled mid-deploy (CI as deployer)interruptible: true inherited from defaultSet interruptible: false on deploy jobs
deploy:production is skipped on main (CI as deployer)CLOUDFLARE_API_TOKEN is unset, or not marked protected, or main is not protectedCheck Settings → CI/CD → Variables and Settings → Repository → Protected branches
Workers Builds deploys a commit the pipeline rejectedSomeone pushed to main directly, or merged with a red pipelineProtected branch with push = no one; “Pipelines must succeed”
Trivy fails with TOOMANYREQUESTS downloading the DBGHCR rate limit from shared runner IPsCache TRIVY_CACHE_DIR as shown; for larger fleets mirror the DB with oras and set --db-repository

The first two rows are not hypothetical: both happened while setting up this repository, and the fixes are visible in its commit history.

Running this in production

  • Pin scanner versions and let Renovate bump them. aquasec/trivy:0.65.0, node:22, busybox:1.36 are all pinned here. An unpinned latest scanner changes behaviour between pipelines and makes “it passed yesterday” impossible to reason about.
  • Treat the pipeline like code: .gitlab-ci.yml and .gitlab/ci/* deserve CODEOWNERS-style review, and any change to a security job needs a description of the threat it addresses, the same rule this repository applies to its headers, CSP and security scripts.
  • Measure gate noise. If a gate fails more than it should for reasons unrelated to the change, developers will learn to retry blindly. --ignore-unfixed, path allowlists and expiring ignore entries exist to keep the signal high.
  • If CI holds a deploy token, rotate it on a schedule and whenever a Maintainer leaves. It is the one credential that can change production. With the platform as deployer there is nothing to rotate in GitLab.
  • Keep one deployment owner. Either the pipeline deploys, or the platform’s Git integration deploys. Never both, or the same commit will be deployed twice by two systems with two different security postures. This site chose the integration; the pipeline has no deploy stage and no credential.

References

Keep reading