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.
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
mainafter 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
- 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. - Quality is three jobs in parallel: Prettier, ESLint and
astro check(TypeScript plus template validation). Each is a gate; none depends on another. - Build produces
dist/(HTML, hashed assets, the search index,_headers) and stores it as a pipeline artifact with a one-week expiry. - 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
installstage, so they start while the build is still running. - 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-evalever appears in a policy. - 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.
- Workers Builds is where production begins. Cloudflare’s Git integration is connected to the repository with
mainas its production branch: the merge commit triggers a build that installs from the same frozen lockfile, runspnpm build, and deploys withwrangler 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.rulesblock 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: trueindefaultlets 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 tofalse, because a deploy must never be cancelled halfway.- The GitLab security templates hard-code
stage: test. If your pipeline has noteststage 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 intosecurityby 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:
| Setting | Value | Why |
|---|---|---|
Repository → Protected branches → main | Allowed to push: No one; allowed to merge: Maintainers; no force push | Every change to main is a merge request with a pipeline |
| Merge requests → Merge checks | Pipelines must succeed; all threads resolved | A red pipeline cannot be merged by habit |
| CI/CD → Variables | None | The pipeline deploys nothing, so it needs no credential |
| CI/CD → Schedules | Nightly, on main | Runs trivy:scheduled against a fresh vulnerability database |
| Cloudflare → Workers Builds | Repository connected, production branch main, build pnpm build | The 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--redactso the job log does not become the second copy of the secret. - Artifacts are retention-bounded.
dist/andtrivy-report.txtexpire 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 deployis atomic per version. Rollback is agit revertand a normal pipeline, not a manual upload.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
stage test is not defined at pipeline creation | The SAST / Secret Detection templates assign stage: test | Declare 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 them | Add the cache directories to .prettierignore and the ESLint ignores list |
| Every job reinstalls dependencies for a minute | Cache key changed, or a job pushes a cache the others cannot use | Key on pnpm-lock.yaml; only install uses policy: pull-push |
secret_detection finds nothing on a merge request with a known leak | Shallow clone; the commit range could not be computed | Set GIT_DEPTH: '0' on the job |
| A pipeline is cancelled mid-deploy (CI as deployer) | interruptible: true inherited from default | Set 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 protected | Check Settings → CI/CD → Variables and Settings → Repository → Protected branches |
| Workers Builds deploys a commit the pipeline rejected | Someone pushed to main directly, or merged with a red pipeline | Protected branch with push = no one; “Pipelines must succeed” |
Trivy fails with TOOMANYREQUESTS downloading the DB | GHCR rate limit from shared runner IPs | Cache 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.36are all pinned here. An unpinnedlatestscanner changes behaviour between pipelines and makes “it passed yesterday” impossible to reason about. - Treat the pipeline like code:
.gitlab-ci.ymland.gitlab/ci/*deserveCODEOWNERS-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