TASK J: GitLab CI — CI Pipeline Guide
Overview
This guide gets your Continuous Integration (CI) pipeline running end-to-end.
You will complete this guide first. The CD (Continuous Delivery) components — Kustomize overlay updates, Argo CD sync — are covered in a separate guide. This separation is intentional: get CI green and your image in the registry first, then layer in CD.
By the end of this guide you will have:
- A GitLab Runner installed and registered on your EC2
- A
.gitlab-ci.ymlthat installs, lints, tests, scans, builds, publishes, and image-scans your Docker image automatically on every push - Your image visible in GitLab's built-in Container Registry
- Your compiled app uploaded to JFrog Artifactory
What this guide does NOT cover yet:
- Kustomize overlay updates
- CD repo commits
- Argo CD syncing
- Kubernetes deployments
Those come in the next guide once this pipeline is green.
Pipeline Overview
The CI pipeline you will build runs these stages in order:
install
└── npm ci — downloads and caches dependencies
test (parallel)
├── lint — ESLint checks
└── unit-tests — Vitest + coverage report
scan (parallel)
├── gitleaks — scans for committed secrets
├── sonarqube — code quality + coverage gate
└── snyk-security — dependency vulnerability scan
build
└── build-app — compiles React/TypeScript → dist/
publish (parallel)
├── docker-publish — builds Docker image, pushes to GitLab registry
└── artifactory-upload — uploads dist/ to JFrog Artifactory
scan-image
└── image-scan — scans the built Docker image for OS-level CVEs (Trivy)
At the end of a successful run you will see:
- A green pipeline in GitLab → CI/CD → Pipelines
- Your image tagged
<pipeline-number>-<git-sha>in GitLab → Deploy → Container Registry - Your build artefacts in JFrog Artifactory
- A Trivy scan report confirming no HIGH or CRITICAL CVEs in the image
Two-Repo Setup — Quick Recap
You have two GitLab repositories:
| Repo | What lives there |
|---|---|
| CI repo | Source code, Dockerfile, .gitlab-ci.yml, tests |
| CD repo | kubernetes/ — Kustomize overlays, Argo CD Application CRDs |
This guide works entirely in the CI repo. You will not touch the CD repo until the next guide.
Step 1: What is GitLab CI and Why Is It Relevant?
GitLab is more than a Git host
GitLab is a unified DevOps platform — it ships Git hosting, CI/CD, a container registry, security scanning, and environment dashboards in a single product. You do not need to wire together separate tools.
For the HealthPulse capstone this matters because:
- Your
.gitlab-ci.ymlsits in the same repo as your source code — no separate CI server to configure - GitLab's built-in Container Registry gives you a free private Docker registry with zero setup (
$CI_REGISTRYis pre-populated automatically) - CI/CD Variables store your secrets — no Vault, no
.envfiles committed by accident - The Pipelines page shows every run, its logs, and pass/fail status in real time
How GitLab CI works — the big picture
Developer pushes to 'develop'
│
▼
GitLab reads .gitlab-ci.yml
│
▼
GitLab queues jobs for your Runner
│
▼
Runner (your EC2) picks up each job
│
▼
Runner starts a fresh Docker container per job
│
▼
Scripts run inside the container
│
▼
Container is destroyed, logs streamed back to GitLab
│
▼
Pipeline shows green ✅ or red ❌
Developer pushes to 'develop'
│
▼
GitLab reads .gitlab-ci.yml
│
▼
GitLab queues jobs for your Runner
│
▼
Runner (your EC2) picks up each job
│
▼
Runner starts a fresh Docker container per job
│
▼
Scripts run inside the container
│
▼
Container is destroyed, logs streamed back to GitLab
│
▼
Pipeline shows green ✅ or red ❌
How jobs actually run — the container model
Each job is a fresh, isolated container that starts when the job begins and is destroyed when it ends.
Each job is a fresh, isolated container that starts when the job begins and is destroyed when it ends.
The pipeline is the orchestrator. The containers are the workers. Here is what the CI pipeline looks like as containers:
Pipeline
├── stage: install
│ └── container: node:24-alpine → runs npm ci → destroyed
├── stage: test (parallel)
│ ├── container: node:24-alpine → runs lint → destroyed
│ └── container: node:24-alpine → runs vitest → destroyed
├── stage: scan (parallel)
│ ├── container: gitleaks:v8.30.1 → scans repo for secrets → destroyed
│ ├── container: sonar-scanner-cli → sends data to SonarQube server → destroyed
│ └── container: node:24-alpine → npx downloads + runs snyk → destroyed
├── stage: build
│ └── container: node:24-alpine → runs npm build → destroyed
├── stage: publish (parallel)
│ ├── container: docker:29.4.3-dind → builds + pushes image → destroyed
│ └── container: jfrog-cli-v2-jf → uploads dist/ to Artifactory → destroyed
└── stage: scan-image
└── container: aquasec/trivy → pulls image from registry, scans for CVEs → destroyed
The only things that survive between containers:
| What | How |
|---|---|
| Artifacts | Files uploaded at end of job, downloaded at start of the next job that needs: it |
| Cache | node_modules/ reused between pipeline runs |
| The registry | The built Docker image — lives there permanently after docker push |
This is why image: is one of the most important keys in a job — it chooses the operating system and pre-installed tools for that container's run. Almost nothing needs to be pre-installed on your EC2 host.
GitLab's built-in container registry — is it free?
Yes. Included on every plan. Container registry storage is not counted against GitLab's 10 GiB project storage quota — it is tracked separately with no published hard limit on the free tier.
GitLab pre-populates three variables in every pipeline automatically:
| Variable | What it contains |
|---|---|
$CI_REGISTRY | Registry hostname only — e.g. registry.gitlab.com |
$CI_REGISTRY_IMAGE | Full registry path for this project — e.g. registry.gitlab.com/your-group/your-project |
$CI_REGISTRY_USER | Temporary username — valid for this pipeline run only |
$CI_REGISTRY_PASSWORD | Temporary password — valid for this job only |
You do not need to add any variables to use it.
$CI_REGISTRYvs$CI_REGISTRY_IMAGE— this is a common mistake.$CI_REGISTRYis only the hostname (registry.gitlab.com). If you tag your image as$CI_REGISTRY/$APP_NAME:version, GitLab will reject the push withdenied: requested access to the resource is deniedbecause the path has no namespace. Always use$CI_REGISTRY_IMAGE/$APP_NAME:version— it includes the full project path and is the only form GitLab will accept for pushing.
Registry options — if you prefer a different registry
This guide uses the GitLab built-in registry by default. The only change needed for other registries is the login command and image tag prefix:
JCR (JFrog): Add variables JCR_REGISTRY, JCR_USER, JCR_TOKEN, then:
before_script:
- echo "$JCR_TOKEN" | docker login -u "$JCR_USER" --password-stdin "$JCR_REGISTRY"
script:
- docker build -t $JCR_REGISTRY/$APP_NAME:$BUILD_VERSION .
- docker push $JCR_REGISTRY/$APP_NAME:$BUILD_VERSIONDockerHub: Add variables DOCKERHUB_USER, DOCKERHUB_TOKEN, then:
before_script:
- echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USER" --password-stdin
script:
- docker build -t $DOCKERHUB_USER/$APP_NAME:$BUILD_VERSION .
- docker push $DOCKERHUB_USER/$APP_NAME:$BUILD_VERSIONThe rest of the pipeline is identical regardless of which registry you choose.
Step 2: What is a GitLab Runner?
The runner is the machine that does the work
GitLab.com is the coordinator — it stores your code, reads your .gitlab-ci.yml, and decides which jobs to run. But it does not execute the jobs itself.
A GitLab Runner is a separate agent process that runs on a machine you control. It polls GitLab for jobs, picks them up, runs the scripts, and streams the logs back.
GitLab Server Your EC2 (runner)
───────────────── ──────────────────────────
Reads .gitlab-ci.yml
Queues jobs Runner polls: "any jobs for me?"
Assigns job to runner ──────► Runner receives job
Runner spins up Docker container
Runner executes scripts inside it
◄────── Runner streams logs back
Stores artefacts Runner uploads artefacts
Shows pass/fail in UI
Executor types
When you register a runner you choose an executor — how each job is isolated:
| Executor | How it works | Use when |
|---|---|---|
| Docker | Each job runs in a fresh container | ✅ Recommended — clean, isolated every time |
| Shell | Scripts run directly on the host OS | Legacy only — no isolation between jobs |
| Kubernetes | A new pod per job | Production-grade at scale — overkill for this project |
| Docker Machine | Autoscaling VMs | ❌ Deprecated by GitLab |
Modern standard: Kubernetes executor is used by large organisations and GitLab.com's own shared runners. For your EC2-based setup, Docker executor is the right choice — it gives you identical container-per-job isolation without the cluster management overhead, and the concepts transfer directly to Kubernetes executor when you reach a production environment.
For the HealthPulse capstone you will use:
- Docker executor for all test, scan, and build jobs
- Docker-in-Docker (dind) as a service inside the
docker-publishjob
Registration token — removed since GitLab 17.0
Before GitLab 17.0 you copied a "Registration Token" from Settings and passed it as --registration-token. That method is disabled by default since GitLab 17.0 and will be fully removed in GitLab 20.0.
New flow: create the runner in the GitLab UI first, get an authentication token (glrt- prefix), then register on EC2 using --token. This guide uses the new flow.
Step 3: Prepare Your EC2 for the Runner
3.1 — Check your EC2 specs
Resource Minimum Recommended CPU 1 vCPU 2 vCPU (t3.medium) RAM 2 GB 4 GB Disk 20 GB 30 GB (Docker image layers cache up fast) OS Ubuntu 20.04 LTS Ubuntu 22.04 / 24.04 LTS
t2.micro will not work. The docker-publish job runs Docker-in-Docker and needs at least 2 GB RAM to build the image without running out of memory.
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 1 vCPU | 2 vCPU (t3.medium) |
| RAM | 2 GB | 4 GB |
| Disk | 20 GB | 30 GB (Docker image layers cache up fast) |
| OS | Ubuntu 20.04 LTS | Ubuntu 22.04 / 24.04 LTS |
t2.micro will not work. The docker-publish job runs Docker-in-Docker and needs at least 2 GB RAM to build the image without running out of memory.
3.2 — Install Docker Engine
Do not use docker.io — it is an unofficial Ubuntu-maintained package that lags on security patches and can conflict with official Docker packages. Docker's own documentation explicitly says to remove it first.
Do not use docker.io — it is an unofficial Ubuntu-maintained package that lags on security patches and can conflict with official Docker packages. Docker's own documentation explicitly says to remove it first.
SSH into your runner EC2 and run:
# Step 1 — Remove any unofficial Docker packages if present
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt-get remove -y $pkg 2>/dev/null || true
done
# Step 2 — Install required packages
sudo apt-get update
sudo apt-get install -y ca-certificates curl
# Step 3 — Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Step 4 — Add the Docker CE repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Step 5 — Install Docker CE
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Step 6 — Start Docker and enable on boot
sudo systemctl start docker
sudo systemctl enable dockerVerify it works:
docker --version
# Expected: Docker version 29.x.x, build ...
sudo docker run hello-world
# Expected: "Hello from Docker!" message3.3 — Why Docker is required
This guide uses the Docker executor. Every CI job specifies an image: — the Runner pulls that image and runs the job scripts inside a fresh container. Without Docker on the host, every job fails with:
Cannot connect to the Docker daemon at unix:///var/run/docker.sock
What needs to be on the host vs what arrives via Docker images:
| Tool | Needed on host? | How it arrives |
|---|---|---|
| Docker Engine | ✅ Yes — install it now | Step 3.2 above |
| Node.js | ❌ No | node:24-alpine image per job |
| SonarScanner | ❌ No | sonarsource/sonar-scanner-cli:12.1 image |
| Gitleaks | ❌ No | ghcr.io/gitleaks/gitleaks:v8.30.1 image |
| Docker CLI (for builds) | ❌ No | docker:29.4.3-dind image |
| git | ✅ Pre-installed on Ubuntu | — |
| curl | ✅ Pre-installed on Ubuntu | — |
Step 4: Install and Register a GitLab Runner
4.1 — Install the runner binary
On the EC2:
# Add the GitLab Runner package repository
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
# Install
sudo apt-get install -y gitlab-runner
# Verify
gitlab-runner --version
# Expected: gitlab-runner 18.x.x (current stable: 18.11)4.2 — Create the runner in GitLab UI
- Go to your CI repo on GitLab
- Click Settings → CI/CD → Runners
- Click New project runner
- Fill in:
- Platform: Linux
- Tags:
healthpulse,docker - Description:
healthpulse-runner - Tick Run untagged jobs
- Click Create runner
- GitLab shows a
gitlab-runner register command with a pre-filled glrt- token — copy the full command
- Platform: Linux
- Tags:
healthpulse,docker - Description:
healthpulse-runner - Tick Run untagged jobs
gitlab-runner register command with a pre-filled glrt- token — copy the full command4.3 — Register the runner on EC2
Paste the command GitLab gave you, but add --docker-privileged before running:
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--token "glrt-t3_xxxxxxxxxxxxxxxxxxxx" \
--executor "docker" \
--docker-image "alpine:latest" \
--docker-privileged \
--description "healthpulse-runner"Why
--docker-privileged? Thedocker-publishjob runs Docker-in-Docker — a container that runsdocker buildinside itself. This requires elevated permissions to talk to the Docker daemon. Without this flag the image build step fails.Only enable privileged mode on a dedicated runner EC2, not on a shared or production server.
4.4 — Start and verify the runner
sudo gitlab-runner start
sudo gitlab-runner status
# Expected: gitlab-runner: Service is running
sudo gitlab-runner list
# Expected: healthpulse-runner Executor=docker Token=glrt-...
sudo gitlab-runner start
sudo gitlab-runner status
# Expected: gitlab-runner: Service is running
sudo gitlab-runner list
# Expected: healthpulse-runner Executor=docker Token=glrt-...Go to Settings → CI/CD → Runners in GitLab. The runner should show a green circle (online).
4.5 — Give the runner access to Docker
sudo usermod -aG docker gitlab-runner
sudo systemctl restart gitlab-runner
# Verify
sudo -u gitlab-runner docker ps
# Expected: empty table (no error)
sudo usermod -aG docker gitlab-runner
sudo systemctl restart gitlab-runner
# Verify
sudo -u gitlab-runner docker ps
# Expected: empty table (no error)Step 5: Configure CI/CD Variables
5.1 — Open the Variables settings
Go to your CI repo → Settings → CI/CD → expand Variables → click Add variable.
5.2 — Variables required for the CI pipeline
Security scanning:
| Key | Value | Protected | Masked |
|---|---|---|---|
SONAR_TOKEN | Your SonarQube user token | Yes | Yes |
SONAR_HOST_URL | http://<SONARQUBE_IP>:9000 | No | No |
SNYK_TOKEN | Your Snyk API token | Yes | Yes |
Artifactory upload:
| Key | Value | Protected | Masked |
|---|---|---|---|
ARTIFACTORY_URL | http://<JCR_IP>:8082/artifactory | No | No |
ARTIFACTORY_USER | healthpulse-deployer | No | No |
ARTIFACTORY_PASSWORD | JCR access token | Yes | Yes |
GitLab registry — nothing to add. $CI_REGISTRY, $CI_REGISTRY_USER, and $CI_REGISTRY_PASSWORD are injected automatically by GitLab into every pipeline.
Variables for CD components (
CD_REPO_TOKEN,CD_REPO_URL, Ansible Tower variables) are added in the CD guide. You do not need them yet.
5.3 — What Protected and Masked mean
Protected — the variable is only injected on protected branches (e.g. main, develop). Use this for tokens and passwords.
Masked — the value is replaced with [MASKED] in job logs. Always mask passwords and tokens.
5.4 — Where to get your Snyk token
- Go to app.snyk.io → Account Settings
- Under Auth Token, click Click to show
- Copy the token and paste it as
SNYK_TOKEN in GitLab
Never call snyk auth in CI. It opens a browser OAuth flow and hangs in headless environments. The SNYK_TOKEN environment variable is the correct approach — the Snyk CLI picks it up automatically.
SNYK_TOKEN in GitLabNever call snyk auth in CI. It opens a browser OAuth flow and hangs in headless environments. The SNYK_TOKEN environment variable is the correct approach — the Snyk CLI picks it up automatically.
Step 6: Understanding the .gitlab-ci.yml
Read this section before creating the file. It explains every concept you will use.
What is it and where does it live?
It is a YAML file at the root of your CI repo. GitLab reads it automatically on every push and uses it to decide what pipeline to run.
healthpulse-ci/ ← your CI repo root
├── src/
├── docker/
│ ├── Dockerfile
│ └── nginx.conf
├── tests/
├── .gitlab-ci.yml ← here, at the root
└── package.json
The Dockerfile — what it is and what it does
The pipeline has two separate steps that together produce the Docker image:
build-app (node:24-alpine) docker-publish (docker:29.4.3-dind)
────────────────────────── ───────────────────────────────────
npm run build → docker build -f docker/Dockerfile
produces dist/ ─────────────────► Dockerfile copies dist/ into Nginx
artifact
build-app runs npm run build inside a Node container. Vite compiles your React/TypeScript source into optimised static files in dist/. That folder is uploaded as a GitLab artifact.
docker-publish receives the dist/ artifact, then runs docker build. The Dockerfile packages those pre-built static files into a lightweight Nginx image.
Create docker/Dockerfile in your CI repo:
# =============================================================
# HealthPulse Portal — Dockerfile
# Serves the pre-compiled React app via Nginx.
#
# The dist/ folder is built by the 'build-app' CI job and
# passed here as a GitLab artifact. This image just packages
# and serves it — no Node.js needed at runtime.
# =============================================================
FROM nginx:1.27-alpine
# Remove the default Nginx welcome page
RUN rm -rf /usr/share/nginx/html/*
# Copy the compiled React app from the CI build-app artifact
COPY dist/ /usr/share/nginx/html
# Copy the custom Nginx config (handles React Router client-side routing)
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]Create docker/nginx.conf in your CI repo:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# React Router — serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Do not cache index.html — always serve the latest version
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Health check endpoint — used by Kubernetes liveness probe
location /health {
return 200 'OK';
add_header Content-Type text/plain;
}
}Commit both files before creating the pipeline:
mkdir -p docker
# create docker/Dockerfile and docker/nginx.conf with the content above
git add docker/Dockerfile docker/nginx.conf
git commit -m "feat: add Dockerfile and Nginx config for React app"
git push origin developTop-level structure
variables: # Global variables available to all jobs
KEY: value
stages: # Order stages run in
- install
- test
job-name: # A job definition
stage: test
script:
- echo "hello"
variables: # Global variables available to all jobs
KEY: value
stages: # Order stages run in
- install
- test
job-name: # A job definition
stage: test
script:
- echo "hello"Anatomy of a job
job-name:
stage: test # Which stage this job belongs to
image: node:24-alpine # Docker image — the container for this job
needs: [install] # Jobs that must finish before this one starts
variables: # Job-level variables (override globals)
MY_VAR: value
before_script: # Runs before script — use for setup (login, installs)
- npm config set ...
script: # The actual commands — this is the job's work
- npm run test
artifacts: # Files to keep and pass to later jobs
paths:
- coverage/
expire_in: 7 days
cache: # Files to reuse between pipeline runs
paths:
- node_modules/
services: # Sidecar containers (e.g. Docker daemon)
- docker:29.4.3-dind
allow_failure: false # If true, pipeline stays green even if this job fails
job-name:
stage: test # Which stage this job belongs to
image: node:24-alpine # Docker image — the container for this job
needs: [install] # Jobs that must finish before this one starts
variables: # Job-level variables (override globals)
MY_VAR: value
before_script: # Runs before script — use for setup (login, installs)
- npm config set ...
script: # The actual commands — this is the job's work
- npm run test
artifacts: # Files to keep and pass to later jobs
paths:
- coverage/
expire_in: 7 days
cache: # Files to reuse between pipeline runs
paths:
- node_modules/
services: # Sidecar containers (e.g. Docker daemon)
- docker:29.4.3-dind
allow_failure: false # If true, pipeline stays green even if this job failsKey concepts explained
image — The Docker image that becomes the container for this job. It provides the OS and pre-installed tools. Each job can use a different image.
needs — Creates a dependency. The job waits for those jobs to finish and downloads their artifacts before starting. Without needs:, jobs in the same stage run in parallel.
artifacts — Files uploaded to GitLab after the job. Any job that needs: this job can download them. This is how dist/ travels from build-app to docker-publish.
cache — Files persisted between pipeline runs (not between jobs). Use for node_modules/ so npm ci does not re-download packages on every push.
services — Sidecar containers that run alongside the job. docker:29.4.3-dind starts a Docker daemon the job's Docker CLI can talk to — this is what Docker-in-Docker means.
allow_failure — Controls whether pipeline fails if this job fails. false (default) means a failing job stops the pipeline. true means the job is informational — it reports findings but does not block.
YAML anchors — avoiding repetition
.node_cache: &node_cache # Define once
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
install:
<<: *node_cache # Merge in — same as writing it out
script:
- npm ci
.node_cache: &node_cache # Define once
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
install:
<<: *node_cache # Merge in — same as writing it out
script:
- npm ci&node_cache defines the anchor. *node_cache references it. <<: merges it into the job. This prevents repeating the same cache config in every job.
How each scan job works
gitleaks
The runner pulls the gitleaks image, starts a container, and GitLab automatically clones your repo into it before your script: runs. The full scan — all files and commit history — happens entirely inside the container.
entrypoint: [""] is required because the gitleaks image sets its default entrypoint to the gitleaks binary. Without overriding it, Docker would pass your script: lines as arguments to gitleaks instead of shell commands — breaking GitLab's wrapper.
needs: [] means it starts the moment the scan stage begins with no dependencies.
sonarqube
Same container mechanism, but needs: [unit-tests] does two things: waits for the job to finish and downloads its artifacts. That is how coverage/lcov.info arrives in this container.
The sonar-scanner CLI is a client — it reads your source files and coverage report, then ships everything to your SonarQube server ($SONAR_HOST_URL). The actual analysis runs on the server. -Dsonar.qualitygate.wait=true tells the scanner to keep polling the server until it returns a pass or fail result before the job exits.
snyk-security
The node:24-alpine image does not contain the Snyk CLI. That is why npx snyk is used. npx is the Node Package Runner — it checks node_modules/.bin/snyk first, then downloads Snyk from npm on the fly if not found.
needs: [install] brings node_modules/ into this container so Snyk can see the exact resolved version of every dependency and transitive dependency — not just the version ranges in package.json. Exact versions are what Snyk checks against its vulnerability database.
| Job | Image contains the tool? | How the tool arrives |
|---|---|---|
gitleaks | ✅ Yes — the image is gitleaks | Built into the image |
sonarqube | ✅ Yes — baked into CLI image | Pre-installed in the image |
snyk-security | ❌ No — plain Node image | npx downloads it at runtime |
Source code scans vs image scan — why you need both
The three scans above all run before the Docker image is built. They look at your source code and your node_modules/ dependencies. They cannot see what is inside the final image.
The image scan runs after docker-publish. It pulls the image that was just pushed to the registry and scans everything inside it — including the OS packages in nginx:1.27-alpine. These are completely different from your npm dependencies.
Source code scans (gitleaks, sonarqube, snyk)
└── What they see: your .ts files, node_modules/, git history
└── What they miss: Alpine Linux packages, Nginx binaries, OpenSSL in the image
Image scan (Trivy)
└── What it sees: every layer of the built Docker image
└── Catches: CVEs in nginx, Alpine libc, OpenSSL, libcrypto — anything in the base image
└── Does not care about: your TypeScript source code
A real-world example: your source code could be perfectly clean, but nginx:1.27-alpine could ship with a version of libssl that has a known CVE. Only an image scan catches that.
Trivy (by Aqua Security) is the de-facto standard for container image scanning. It is free, fast, and runs entirely inside the container — no external service needed. It downloads its vulnerability database at runtime from Aqua's public DB.
image-scan:
image:
name: aquasec/trivy:latest
entrypoint: [""]
needs: [docker-publish]
variables:
TRIVY_USERNAME: $CI_REGISTRY_USER # Trivy uses these to pull the image
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD # from the GitLab registry for scanning
script:
- trivy image --exit-code 1 --severity HIGH,CRITICAL
--no-progress --format table
--output trivy-results.txt
$CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION--exit-code 1 means Trivy exits with code 1 (fail) if it finds any HIGH or CRITICAL CVEs — blocking the pipeline. --severity HIGH,CRITICAL ignores LOW and MEDIUM findings. You can tighten or loosen this threshold as your team's policy dictates.
TRIVY_USERNAME and TRIVY_PASSWORD are set to GitLab's auto-injected registry credentials so Trivy can pull the image it needs to scan. Without these, Trivy cannot authenticate with a private registry.
Step 7: Create the .gitlab-ci.yml
Before you create the pipeline file — create .gitleaks.toml
The gitleaks job references a .gitleaks.toml config file. If this file does not exist in your repo, the job will fail immediately with:
FTL unable to load gitleaks config, err: open .gitleaks.toml: no such file or directory
Create .gitleaks.toml at the root of your repo (same level as .gitlab-ci.yml, not inside the app subfolder):
# .gitleaks.toml
# Gitleaks configuration for HealthPulse CI pipeline.
# This file must exist at the repo root.
# The gitleaks job scans the entire repo from root — not the app subfolder —
# which is why it uses before_script: [] to override the default cd into healthpulse-app.
title = "HealthPulse Gitleaks Config"
[extend]
# Use gitleaks' built-in default ruleset.
# These rules detect AWS keys, GitHub tokens, private keys, connection strings, etc.
useDefault = true
# Add allowlist entries here to suppress false positives.
# Example — suppress a specific file:
# [[allowlists]]
# description = "Ignore test fixtures"
# paths = ["tests/fixtures/sample-data.json"]Commit it before pushing .gitlab-ci.yml:
# Create the file at repo root (not inside healthpulse-app/)
git add .gitleaks.toml
git commit -m "feat: add gitleaks config"
git push origin developWhy
before_script: []on the gitleaks job?The
default: before_scriptin your pipeline doescd healthpulse-app— which moves every job into the app subfolder. But gitleaks should scan the entire repository (all files, all commit history), not just the app subfolder. Settingbefore_script: []on the gitleaks job overrides the default for that job only, keeping it at the repo root where it belongs.
In the root of your CI repo, create .gitlab-ci.yml with the following content.
⚠️ Where this file must liveGitLab always runs CI jobs from the repository root — the directory that contains the
.git/folder. It does not matter where.gitlab-ci.ymlitself is; the working directory for every job is always the repo root.If your app code is inside a subfolder (e.g.
healthpulse-app/),npm ciwill fail because it cannot findpackage-lock.jsonat the root. Fix this by addingdefault: before_script: [cd healthpulse-app]at the top of the file and prefixing all artifact paths withhealthpulse-app/. See the Troubleshooting section for the full fix.The cleanest setup is a single-app repo where all source files are at the root — no subdirectory needed.
Option A — on your local machine:
cd ~/projects/healthpulse-ci # adjust to wherever you cloned it
touch .gitlab-ci.yml
# open in your editor, paste the content below, saveOption B — GitLab web editor:
- Go to your CI repo on GitLab
- Click + → New file
- File name:
.gitlab-ci.yml - Paste the content below
# =============================================================
# HealthPulse Portal — GitLab CI Pipeline (CI only)
# Repo: CI repo (source code)
# Flow: Install → Test → Scan → Build → Publish
#
# CD components (Kustomize overlay updates, Argo CD sync)
# are added in the next guide after this pipeline is green.
# =============================================================
# ─────────────── GLOBAL VARIABLES ───────────────
variables:
APP_NAME: healthpulse-portal
NODE_IMAGE: node:24-alpine
# ─────────────── PIPELINE STAGES ───────────────
stages:
- install # npm ci — download dependencies
- test # lint + unit tests (parallel)
- scan # gitleaks + sonarqube + snyk (parallel)
- build # npm run build — compile the React app
- publish # docker build + push + artifactory upload (parallel)
- scan-image # trivy — scan the built Docker image for OS-level CVEs
# ─────────────── YAML ANCHOR — node cache ───────────────
.node_cache: &node_cache
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
# =============================================================
# STAGE: INSTALL
# Downloads npm dependencies and caches them.
# Uploads node_modules/ as an artifact so later jobs get it
# without repeating the install.
# =============================================================
install:
stage: install
image: $NODE_IMAGE
<<: *node_cache
script:
- npm ci
artifacts:
paths:
- node_modules/
expire_in: 1 hour
# =============================================================
# STAGE: TEST
# lint and unit-tests run in parallel — both need node_modules.
# unit-tests uploads the coverage report as an artifact for
# SonarQube to use in the scan stage.
# =============================================================
lint:
stage: test
image: $NODE_IMAGE
needs: [install]
script:
- npm run lint
unit-tests:
stage: test
image: $NODE_IMAGE
needs: [install]
script:
- npm run test:coverage
artifacts:
when: always # Upload coverage even if tests fail (useful for debugging)
paths:
- coverage/
reports:
junit: test-results/*.xml
expire_in: 7 days
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
# =============================================================
# STAGE: SCAN
# Three scans run in parallel after tests pass.
#
# gitleaks: checks for secrets committed to the repo
# sonarqube: code quality + coverage gate
# snyk-security: dependency vulnerability scan
#
# gitleaks and sonarqube are blocking (allow_failure: false).
# snyk is informational (allow_failure: true).
# =============================================================
gitleaks:
stage: scan
image:
name: ghcr.io/gitleaks/gitleaks:v8.30.1
entrypoint: [""] # Override image entrypoint so GitLab can run its own shell script
before_script: [] # Override default — gitleaks must scan from REPO ROOT, not the app subfolder
needs: [] # No dependency — runs immediately when scan stage starts
script:
- gitleaks detect --source . --config .gitleaks.toml --verbose
allow_failure: false # Pipeline fails if secrets are found
sonarqube:
stage: scan
image:
name: sonarsource/sonar-scanner-cli:12.1
entrypoint: [""]
needs: [unit-tests] # Needs coverage report from unit-tests artifacts
variables:
SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar" # Cache scanner rules/plugins
script:
- sonar-scanner
-Dsonar.projectKey=$APP_NAME
-Dsonar.projectName="HealthPulse Portal"
-Dsonar.sources=src
-Dsonar.exclusions="**/test/**,**/node_modules/**"
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
-Dsonar.host.url=$SONAR_HOST_URL
-Dsonar.token=$SONAR_TOKEN
-Dsonar.qualitygate.wait=true # Block pipeline until quality gate result
allow_failure: false
snyk-security:
stage: scan
image: $NODE_IMAGE
needs: [install] # Needs node_modules for accurate dependency tree scanning
variables:
# SNYK_TOKEN is set as a CI/CD Variable (masked + protected).
# The Snyk CLI picks it up automatically — no 'snyk auth' call needed in CI.
# 'snyk auth' opens a browser OAuth flow and will hang in headless environments.
SNYK_TOKEN: $SNYK_TOKEN
script:
- npx snyk test --severity-threshold=high --json > snyk-results.json || true
- npx snyk monitor --project-name=$APP_NAME
artifacts:
paths:
- snyk-results.json
expire_in: 30 days
allow_failure: true # Snyk findings are reported but do not block the pipeline
# =============================================================
# STAGE: BUILD
# Compiles the React/TypeScript app to static files in dist/.
# Waits for lint and unit-tests to both pass before building.
# The dist/ folder is uploaded as an artifact for docker-publish.
# =============================================================
build-app:
stage: build
image: $NODE_IMAGE
needs: [install, lint, unit-tests]
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 7 days
# =============================================================
# STAGE: PUBLISH
# Two jobs run in parallel:
#
# docker-publish: builds the Docker image and pushes to
# GitLab's built-in container registry.
# Also tags as :latest for convenience.
#
# artifactory-upload: uploads the compiled dist/ folder to
# JFrog Artifactory for traceability.
#
# BUILD_VERSION format: {pipeline-number}-{git-sha}
# Example: 42-a1b2c3d — unique, traceable, sortable
# =============================================================
docker-publish:
stage: publish
image: docker:29.4.3-dind
services:
- docker:29.4.3-dind # Starts the Docker daemon this job will use
needs: [build-app, sonarqube]
variables:
BUILD_VERSION: "${CI_PIPELINE_IID}-${CI_COMMIT_SHORT_SHA}"
before_script:
# This job defines its own before_script (docker login), so the default
# 'cd healthpulse-app' does NOT run here. The job runs from repo root.
# $CI_REGISTRY_PASSWORD is a short-lived token GitLab generates per job.
- echo "${CI_REGISTRY_PASSWORD}" | docker login -u "${CI_REGISTRY_USER}" --password-stdin "${CI_REGISTRY}"
script:
# $CI_REGISTRY_IMAGE = full registry path including namespace + project
# e.g. registry.gitlab.com/your-group/your-project
# Do NOT use $CI_REGISTRY here — that is only the hostname (registry.gitlab.com)
# and GitLab will deny the push with "access denied" if the namespace is missing.
#
# -f healthpulse-app/docker/Dockerfile — path to Dockerfile from repo root
# healthpulse-app/ — build context set to app subfolder
# This means COPY dist/ and COPY docker/nginx.conf inside the Dockerfile
# resolve relative to healthpulse-app/ — where the build-app artifact landed.
# If your app is at repo root (no subfolder), use: -f docker/Dockerfile .
- docker build -f healthpulse-app/docker/Dockerfile -t $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION healthpulse-app/
# Push the versioned tag — this is what Argo CD will deploy later
- docker push $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION
# Also tag and push :latest — useful for manual pulls
- docker tag $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION $CI_REGISTRY_IMAGE/$APP_NAME:latest
- docker push $CI_REGISTRY_IMAGE/$APP_NAME:latest
artifactory-upload:
stage: publish
image: releases-docker.jfrog.io/jfrog/jfrog-cli-v2-jf:2.103.0
needs: [build-app]
variables:
BUILD_VERSION: "${CI_PIPELINE_IID}-${CI_COMMIT_SHORT_SHA}"
script:
- jfrog rt upload "dist/**" "healthpulse-builds/$BUILD_VERSION/"
--url=$ARTIFACTORY_URL
--user=$ARTIFACTORY_USER
--password=$ARTIFACTORY_PASSWORD
--build-name=$APP_NAME
--build-number=$BUILD_VERSION
# =============================================================
# STAGE: SCAN-IMAGE
# Scans the built Docker image for OS-level CVEs using Trivy.
# Runs AFTER docker-publish — it pulls the image from the
# registry and scans every layer including the base OS packages.
#
# This is separate from the source code scans (gitleaks, snyk)
# which cannot see inside the Docker image. Trivy catches CVEs
# in nginx, Alpine Linux, OpenSSL — anything in the base image.
#
# TRIVY_USERNAME / TRIVY_PASSWORD — GitLab's auto-injected
# registry credentials. Trivy needs these to pull the private
# image from the GitLab Container Registry for scanning.
# =============================================================
image-scan:
stage: scan-image
image:
name: aquasec/trivy:latest
entrypoint: [""]
needs: [docker-publish]
variables:
BUILD_VERSION: "${CI_PIPELINE_IID}-${CI_COMMIT_SHORT_SHA}"
TRIVY_USERNAME: $CI_REGISTRY_USER # Authenticate with GitLab registry
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD # to pull the image for scanning
script:
# Scan the image — fail pipeline on any HIGH or CRITICAL CVE
# --exit-code 1 → exit with failure if findings match severity filter
# --severity → only report HIGH and CRITICAL (ignore LOW/MEDIUM)
# --no-progress → cleaner CI log output
# --format table → human-readable table output
# --output → save report to file (uploaded as artifact)
- trivy image
--exit-code 1
--severity HIGH,CRITICAL
--no-progress
--format table
--output trivy-results.txt
$CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION
- cat trivy-results.txt
artifacts:
when: always # Save report even if scan fails — useful for review
paths:
- trivy-results.txt
expire_in: 30 days
allow_failure: false # Pipeline fails if HIGH or CRITICAL CVEs are foundStep 8: Push and Watch Your First Pipeline
8.1 — Commit and push
git add .gitlab-ci.yml
git commit -m "feat: add GitLab CI pipeline"
git push origin develop
git add .gitlab-ci.yml
git commit -m "feat: add GitLab CI pipeline"
git push origin develop8.2 — Watch the pipeline run
Go to your CI repo → CI/CD → Pipelines. A pipeline appears within a few seconds.
Click on it to see the stage graph. Jobs turn orange (running) and then green (passed) or red (failed) in real time.
Expected run order:
install
└─► lint (parallel with unit-tests)
└─► unit-tests (parallel with lint)
└─► sonarqube (waits for coverage)
└─► gitleaks (starts immediately)
└─► snyk-security (waits for node_modules)
└─► build-app (waits for lint + unit-tests)
└─► docker-publish (parallel with artifactory-upload)
└─► artifactory-upload (parallel with docker-publish)
└─► image-scan (waits for docker-publish)
8.3 — Read job logs
Click any job to see its full log output. Useful things to look for:
| Job | What a successful log looks like |
|---|---|
install | added N packages — npm finished |
lint | No output or N problems found |
unit-tests | N tests passed with coverage summary |
gitleaks | No leaks found |
sonarqube | Quality Gate status: PASSED |
build-app | dist/ built in Ns |
docker-publish | digest: sha256:... — image pushed |
image-scan | Total: 0 (HIGH: 0, CRITICAL: 0) — no CVEs found |
Step 9: When CI Catches Real Code Issues
This is CI working correctly — not a CI configuration problem.
Your job as a DevOps engineer is to understand what CI caught, communicate it clearly to the development team, and keep the pipeline moving. On a real project you would raise these as tickets for the developers to fix. For this lab, the fixes are provided below as ready-to-paste file replacements — copy them over and move on.
This is CI working correctly — not a CI configuration problem.
Your job as a DevOps engineer is to understand what CI caught, communicate it clearly to the development team, and keep the pipeline moving. On a real project you would raise these as tickets for the developers to fix. For this lab, the fixes are provided below as ready-to-paste file replacements — copy them over and move on.
What CI caught and why it matters
The lint job failed with 5 problems (3 errors, 2 warnings):
src/components/ui/input.tsx:4 error Empty interface — does nothing
src/hooks/use-toast.ts:14 error Variable declared but only used as a type
tests/unit/utils.test.ts:10 error Constant in && always evaluates the same way
src/components/ui/badge.tsx:39 warning File mixes component and non-component exports
src/components/ui/button.tsx:55 warning File mixes component and non-component exports
The unit-tests job failed with coverage below threshold:
Lines: 25.33% (required: 80%)
Functions: 53.84% (required: 80%)
Statements: 25.33% (required: 80%)
Branches: 64.70% (required: 80%)
What this means in plain terms:
The development team shipped code with TypeScript/ESLint violations and an insufficient test suite. These are standard code quality gates that exist on every professional project. CI caught them before deployment — which is exactly what it is designed to do. On a live production system, these issues would have reached users.
Option A — Skip the fixes for now (temporary bypass)
If you want to see the rest of the pipeline run without applying the code fixes yet, add allow_failure: true to the failing jobs in your .gitlab-ci.yml.
When a job has allow_failure: true, GitLab marks it with an orange warning instead of a red failure — the pipeline continues through all remaining stages as if the job passed.
lint:
stage: test
image: $NODE_IMAGE
needs: [install]
script:
- npm run lint
allow_failure: true # ← pipeline continues even if lint errors are found
unit-tests:
stage: test
image: $NODE_IMAGE
needs: [install]
script:
- npm run test:coverage
allow_failure: true # ← pipeline continues even if coverage threshold is not met
artifacts:
when: always
paths:
- coverage/
reports:
junit: test-results/*.xml
expire_in: 7 days
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'Push this change and the pipeline will run all the way through to image-scan.
This is a temporary measure only. On a real project,
allow_failure: trueon a lint or test job means you have consciously accepted that broken code can proceed to build and deploy. It is used while fixes are being worked on — not as a permanent state. Remove it once the code fixes below are applied.
Option B — Apply the permanent fixes
The rest of this step provides complete replacement files for every issue CI found. Apply these when you are ready to close the gaps properly.
Fix 1 — src/components/ui/input.tsx
What CI caught: An empty interface (InputProps) that declares no members. It is identical to its parent type and adds no value. ESLint rule: @typescript-eslint/no-empty-object-type.
Go to: src/components/ui/input.tsx Replace the entire file with:
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };What changed: Line 4 — interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} became type InputProps = React.InputHTMLAttributes<HTMLInputElement>. One word change, same runtime behaviour.
Fix 2 — src/hooks/use-toast.ts
What CI caught: actionTypes is declared as a runtime constant but is only ever used in TypeScript type positions (typeof actionTypes). The JavaScript value is bundled and shipped to users but never actually executed. ESLint rule: @typescript-eslint/no-unused-vars.
Go to: src/hooks/use-toast.ts Replace the entire file with:
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
| { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
| { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] }
| { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] };
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) return;
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({ type: actionTypes.REMOVE_TOAST, toastId });
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case actionTypes.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case actionTypes.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case actionTypes.DISMISS_TOAST: {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((t) => addToRemoveQueue(t.id));
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? { ...t, open: false }
: t,
),
};
}
case actionTypes.REMOVE_TOAST:
if (action.toastId === undefined) return { ...state, toasts: [] };
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => listener(memoryState));
}
function toast({ ...props }: Omit<ToasterToast, "id">) {
const id = genId();
const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id });
dispatch({
type: actionTypes.ADD_TOAST,
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id,
dismiss,
update: (props: ToasterToast) =>
dispatch({ type: actionTypes.UPDATE_TOAST, toast: { ...props, id } }),
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) listeners.splice(index, 1);
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) =>
dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
};
}
export { useToast, toast };What changed: All dispatch calls and switch cases now use actionTypes.ADD_TOAST, actionTypes.DISMISS_TOAST etc. instead of string literals. This makes actionTypes a runtime value, not just a type reference — the linter error disappears.
Fix 3 — tests/unit/utils.test.ts
What CI caught: The test at line 10 uses false && "hidden" directly in the function call. false is a constant — it can never be anything other than false, so the && always produces false. ESLint rule: no-constant-binary-expression. This means the test was not actually testing what the developer intended.
Go to: tests/unit/utils.test.ts Replace the entire file with:
import { describe, it, expect } from "vitest";
import { cn, formatDate, getInitials } from "@/lib/utils";
describe("cn", () => {
it("merges class names", () => {
expect(cn("foo", "bar")).toBe("foo bar");
});
it("handles conditional classes", () => {
const isHidden = false;
expect(cn("base", isHidden && "hidden", "visible")).toBe("base visible");
});
it("merges tailwind conflicts correctly", () => {
expect(cn("px-4", "px-6")).toBe("px-6");
});
});
describe("formatDate", () => {
it("formats a date string", () => {
const result = formatDate("2026-03-14");
expect(result).toContain("Mar");
expect(result).toContain("14");
expect(result).toContain("2026");
});
it("formats a Date object", () => {
const result = formatDate(new Date("2026-01-01"));
expect(result).toContain("Jan");
});
});
describe("getInitials", () => {
it("returns initials from full name", () => {
expect(getInitials("Sarah Johnson")).toBe("SJ");
});
it("handles single name", () => {
expect(getInitials("Sarah")).toBe("S");
});
it("limits to two characters", () => {
expect(getInitials("Sarah Marie Johnson")).toBe("SM");
});
});What changed: Line 10 — false && "hidden" became const isHidden = false assigned on the previous line, then isHidden && "hidden" used in the call. The test now tests real conditional logic. Same runtime behaviour, no more lint error.
Fix 4 — src/components/ui/badge.tsx
What CI caught: The file exports both Badge (a React component) and badgeVariants (a styling function). React's Fast Refresh — the feature that hot-reloads components during development without losing state — cannot reliably handle files that mix component and non-component exports. ESLint rule: react-refresh/only-export-components.
Go to: src/components/ui/badge.tsx Replace the entire file with:
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200",
warning:
"border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };What changed: /* eslint-disable react-refresh/only-export-components */ added at the top. This is a deliberate, documented suppression — badgeVariants must be exported from this file because other components import it. Moving it to a separate file would break imports across the app. The disable comment makes the exception explicit and visible in code review.
Fix 5 — src/components/ui/button.tsx
What CI caught: Same issue as badge.tsx — the file exports both Button (a React component) and buttonVariants (a styling function). ESLint rule: react-refresh/only-export-components.
Go to: src/components/ui/button.tsx Replace the entire file with:
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };What changed: /* eslint-disable react-refresh/only-export-components */ added at the top. Same reason as badge.tsx — buttonVariants is used throughout the app and cannot be moved.
Fix 6 — vitest.config.ts (coverage threshold)
What CI caught: The test suite only covers 25% of the codebase. The config requires 80%. Every page component (Dashboard.tsx, Appointments.tsx, Login.tsx, etc.) has 0% test coverage — meaning no automated verification exists for the core user-facing screens.
Reading the coverage report:
File | % Stmts | % Lines ← what CI measured
Dashboard.tsx | 0 | 0 ← zero tests for this page
Appointments.tsx | 0 | 0 ← zero tests for this page
Login.tsx | 0 | 0 ← zero tests for the login screen
api.ts | 100 | 100 ← well tested
use-auth-store.ts | 94.73 | 94.73 ← well tested
What this means for production: If a developer breaks the Dashboard page, CI would not catch it. It would deploy. Users would see a broken health portal.
The correct fix is for the development team to write tests for every page component. That is developer work and takes time.
Temporary fix for this lab — lower the thresholds in vitest.config.ts to match the current actual coverage so the pipeline can pass while tests are being written:
Go to: vitest.config.ts Replace the entire file with:
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./tests/unit/setup.ts"],
include: ["tests/unit/**/*.{test,spec}.{ts,tsx}"],
reporters: ["default", "junit"],
outputFile: {
junit: "./test-results/junit.xml",
},
coverage: {
provider: "v8",
reporter: ["text", "json", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/main.tsx",
"src/vite-env.d.ts",
"src/**/*.d.ts",
"src/components/ui/**",
],
thresholds: {
// TODO: raise these back to 80% once page component tests are written
// Current actual coverage: lines 25%, functions 53%, branches 64%
branches: 60,
lines: 20,
functions: 50,
statements: 20,
},
},
},
});What changed:
- Thresholds lowered to just below current actual coverage — pipeline passes, but the gap is documented in a
TODOcomment so it is visible to everyone reporters: ["default", "junit"]andoutputFileadded — this generates the JUnit XML file that GitLab uses to display the Tests tab on each pipeline run
Summary — what to copy and where
File What to do src/components/ui/input.tsxReplace full file — Fix 1 above src/hooks/use-toast.tsReplace full file — Fix 2 above tests/unit/utils.test.tsReplace full file — Fix 3 above src/components/ui/badge.tsxReplace full file — Fix 4 above src/components/ui/button.tsxReplace full file — Fix 5 above vitest.config.tsReplace full file — Fix 6 above
| File | What to do |
|---|---|
src/components/ui/input.tsx | Replace full file — Fix 1 above |
src/hooks/use-toast.ts | Replace full file — Fix 2 above |
tests/unit/utils.test.ts | Replace full file — Fix 3 above |
src/components/ui/badge.tsx | Replace full file — Fix 4 above |
src/components/ui/button.tsx | Replace full file — Fix 5 above |
vitest.config.ts | Replace full file — Fix 6 above |
After replacing all six files, commit and push:
git add src/components/ui/input.tsx \
src/hooks/use-toast.ts \
tests/unit/utils.test.ts \
src/components/ui/badge.tsx \
src/components/ui/button.tsx \
vitest.config.ts
git commit -m "fix: resolve CI lint errors and lower coverage thresholds"
git push origin mainThe lint and unit-tests jobs will pass. The pipeline will continue to the scan and build stages.
Step 10: Verify the Image in GitLab Container Registry
After docker-publish completes, navigate to your image in the GitLab sidebar:
Your CI repo (left sidebar)
└── Deploy
└── Container Registry ← your images live here
Can't find it? Older GitLab versions show it under Packages and registries → Container Registry instead. If neither appears, go to Settings → General → Visibility, project features, permissions and confirm Container Registry is enabled for the project.
- Go to your CI repo → Deploy → Container Registry
- You should see:
healthpulse-portal
latest pushed just now registry.gitlab.com/your-group/healthpulse-ci:latest
42-a1b2c3d pushed just now registry.gitlab.com/your-group/healthpulse-ci:42-a1b2c3d
Pull the image to verify it runs
On any machine with Docker installed and access to GitLab:
# Log in
echo "<your-personal-access-token>" | docker login registry.gitlab.com \
-u <your-gitlab-username> --password-stdin
# Pull the versioned image
docker pull registry.gitlab.com/<your-group>/healthpulse-ci:latest
# Run it locally
docker run -p 8080:80 registry.gitlab.com/<your-group>/healthpulse-ci:latestOpen http://localhost:8080 in your browser. You should see the HealthPulse Portal.
If the image runs correctly locally, your Dockerfile and Nginx config are working. This is the same image that will be deployed to Kubernetes in the CD guide.
Step 11: Verify Artifactory Upload
After artifactory-upload completes:
- Open
http://<JCR_IP>:8082in your browser - Log in to JFrog Artifactory
- Go to Artifactory → Artifacts
- Browse to
healthpulse-builds/<pipeline-number>-<sha>/
You should see the contents of dist/ uploaded there — index.html, JS bundles, CSS, assets.
What's Next — Adding CD Components
Now that CI is green and your image is in the registry, you are ready to add the CD pipeline.
The CD guide covers:
- Creating and configuring the CD repo (
healthpulse-cd) - Adding
CD_REPO_TOKENandCD_REPO_URLvariables - Adding
update-dev-manifeststage — CI commits the new image tag to the CD repo - Adding
update-uat-manifeststage — runs onrelease/*branches - Connecting Argo CD to watch the CD repo
- The
[skip ci]rules pattern that prevents an infinite loop - Prod promotion via merge request — no CI stage writes to
overlays/prod/
Do not add CD stages to this pipeline yet. Complete the CD guide as a separate step. Mixing them before CI is stable makes debugging significantly harder.
Acceptance Criteria
Before marking this step complete, verify every item:
- GitLab Runner installed on EC2, Docker executor, privileged mode enabled
- Runner shows green (online) in Settings → CI/CD → Runners
- All CI/CD Variables configured:
SONAR_TOKEN,SONAR_HOST_URL,SNYK_TOKEN,ARTIFACTORY_URL,ARTIFACTORY_USER,ARTIFACTORY_PASSWORD -
docker/Dockerfileanddocker/nginx.confcommitted to the CI repo -
.gitlab-ci.ymlcommitted to the root of the CI repo - Push to
developtriggers a pipeline - Lint errors resolved — no
eslinterrors in the codebase (orallow_failure: trueapplied temporarily with a fix ticket open) - Coverage threshold met — or threshold intentionally lowered with a plan to increase it
- All six stages pass: install → test → scan → build → publish → scan-image
- Docker image appears in Deploy → Container Registry tagged
<iid>-<sha> - Image tagged
latestalso present - Image runs locally and serves the HealthPulse Portal on port 8080
- Build artefacts visible in JFrog Artifactory under
healthpulse-builds/<version>/ -
image-scanjob passes —trivy-results.txtartifact downloadable from the job - Trivy report shows
Total: 0 (HIGH: 0, CRITICAL: 0)or all findings are acknowledged
Troubleshooting
Runner is offline (grey circle in GitLab)
sudo gitlab-runner status
sudo gitlab-runner start
sudo gitlab-runner verify
sudo journalctl -u gitlab-runner -f
sudo gitlab-runner status
sudo gitlab-runner start
sudo gitlab-runner verify
sudo journalctl -u gitlab-runner -finstall fails — "package-lock.json not found" even though the file exists in the repo
This happens when your app lives inside a subfolder of the repository (e.g. healthpulse-app/) rather than at the root.
GitLab always runs CI jobs from the repository root, not from the folder where .gitlab-ci.yml lives. So npm ci runs at /repo-root/ — but package-lock.json is at /repo-root/healthpulse-app/package-lock.json. GitLab cannot find it.
You can confirm this by checking the commit message shown in the job's right panel:
Update healthpulse-app/.gitlab-ci.yml file ← subfolder is the giveaway
Three ways to fix it — pick one:
Option 1 — Recommended: default: before_script
Add this at the very top of .gitlab-ci.yml, before variables::
default:
before_script:
- cd healthpulse-app # replace with your actual subfolder nameThis runs cd healthpulse-app automatically before every job. Important: script: and before_script: run inside the cd'd directory, but artifact and cache paths are always resolved from the repo root. You must prefix every artifact and cache path:
# cache anchor
.node_cache: &node_cache
cache:
key:
files:
- healthpulse-app/package-lock.json
paths:
- healthpulse-app/node_modules/
# install
artifacts:
paths:
- healthpulse-app/node_modules/
# unit-tests
artifacts:
paths:
- healthpulse-app/coverage/
reports:
junit: healthpulse-app/test-results/*.xml
# build-app
artifacts:
paths:
- healthpulse-app/dist/
# snyk-security
artifacts:
paths:
- healthpulse-app/snyk-results.json
# image-scan
artifacts:
paths:
- healthpulse-app/trivy-results.txtIf you see WARNING: node_modules/: no matching files in the install log — this is exactly the cause. The cd changed your script directory but GitLab is still looking for the path from the repo root.
Option 2 — Per-job: add cd to each script
Add cd healthpulse-app as the first line of every job's script: block. More repetitive but explicit.
Option 3 — Best long-term: move app files to the repo root
If the repo contains only this one app, move everything out of the subfolder to the root. No cd needed anywhere and the pipeline works without any changes:
repo-root/
├── .gitlab-ci.yml
├── package.json
├── package-lock.json
├── src/
└── docker/
Jobs fail with "Cannot connect to the Docker daemon" or "permission denied on docker.sock"
ERROR: Preparation failed: getting docker info: permission denied while trying to connect
to the Docker daemon socket at unix:///var/run/docker.sock: dial unix /var/run/docker.sock:
connect: permission denied
ERROR: Preparation failed: getting docker info: permission denied while trying to connect
to the Docker daemon socket at unix:///var/run/docker.sock: dial unix /var/run/docker.sock:
connect: permission denied
or
Cannot connect to the Docker daemon at unix:///var/run/docker.sock
Both errors mean the gitlab-runner user cannot access the Docker socket. This happens on your EC2 runner — not on shared runners.
Two possible causes:
- Docker is not installed — run Step 3.2
gitlab-runneris not in thedockergroup (most common):
sudo usermod -aG docker gitlab-runner
sudo systemctl restart gitlab-runner
# Verify — should return an empty table, not a permission error
sudo -u gitlab-runner docker psIf you also see
ERROR: Failed to remove network for build— this is a leftover from a previous failed job where Docker couldn't clean up. It clears automatically once the permission issue is resolved and the runner can connect to Docker again.
docker-publish fails with "permission denied" on docker socket
The runner was not registered with --docker-privileged. Re-register:
sudo gitlab-runner unregister --name "healthpulse-runner"
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--token "glrt-..." \
--executor "docker" \
--docker-image "alpine:latest" \
--docker-privileged \
--description "healthpulse-runner"sonarqube job fails — "Could not connect to SonarQube server"
- Check
SONAR_HOST_URL is set correctly (no trailing slash, correct port) - Check the SonarQube server is running:
curl http://<SONARQUBE_IP>:9000/api/system/status - Check the security group on the SonarQube EC2 allows inbound TCP 9000 from the runner EC2
SONAR_HOST_URL is set correctly (no trailing slash, correct port)curl http://<SONARQUBE_IP>:9000/api/system/statusgitleaks fails — "unable to load gitleaks config, no such file or directory"
FTL unable to load gitleaks config, err: open .gitleaks.toml: no such file or directory
FTL unable to load gitleaks config, err: open .gitleaks.toml: no such file or directory
The .gitleaks.toml config file does not exist in your repo. The job references it with --config .gitleaks.toml but the file was never committed.
Fix: Create .gitleaks.toml at the repo root and commit it. See Step 7 above for the full file content.
# Quick check — does the file exist at repo root?
ls .gitleaks.tomlAlso check your gitleaks job has before_script: [] — without this, the default: before_script will cd healthpulse-app before gitleaks runs, making it look for the config inside the subfolder and scan only that folder instead of the whole repo:
gitleaks:
stage: scan
image:
name: ghcr.io/gitleaks/gitleaks:v8.30.1
entrypoint: [""]
before_script: [] # ← required — overrides the default cd into healthpulse-app
needs: []
script:
- gitleaks detect --source . --config .gitleaks.toml --verbose
allow_failure: falsegitleaks fails — real secrets found
This is the correct behaviour. Gitleaks found actual secrets in your code or commit history. You must:
- Rotate the exposed credential immediately
- Remove the secret from the codebase
- If it is in git history, you need to rewrite history with
git filter-repo - Add the pattern to
.gitleaks.tomlto allowlist false positives
unit-tests pass locally but fail in CI
Common causes:
- Tests depend on environment variables not set in CI — add them as CI/CD Variables
- Tests use
Date.now()or timezone-sensitive functions — CI runner timezone may differ - Test imports use absolute paths that work locally but fail in Alpine container
docker-publish fails — "pnpm-lock.yaml: not found" or "package.json: not found"
ERROR: failed to calculate checksum of ref ...: "/pnpm-lock.yaml": not found
ERROR: failed to calculate checksum of ref ...: "/pnpm-lock.yaml": not found
Your Dockerfile is a multi-stage build that tries to compile the app from source inside Docker:
FROM node:20-alpine AS build ← compiling inside Docker
COPY pnpm-lock.yaml package.json ./
RUN pnpm install --frozen-lockfile
RUN pnpm buildThis conflicts with the CI pipeline design. The build-app CI job already compiled the app and produced dist/ as an artifact. The docker-publish job receives that dist/ folder and Docker's only job is to package it into an Nginx image — no Node.js, no compilation needed.
Using a multi-stage build here means:
- Docker tries to re-compile the app from scratch inside the container
- It looks for
pnpm-lock.yaml(orpackage.json) in the build context, which only contains thedist/artifact — not the full source - The build fails because the source files are not there
Fix: Replace your Dockerfile with the simple packaging version:
FROM nginx:1.27-alpine
RUN rm -rf /usr/share/nginx/html/*
# Copy the pre-compiled dist/ from the build-app CI artifact
COPY dist/ /usr/share/nginx/html
# Copy Nginx config
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]This Dockerfile has no build stage — it just takes the already-compiled dist/ and serves it via Nginx. The CI pipeline handles compilation; Docker handles packaging.
If your project uses pnpm instead of npm: The CI pipeline also needs updating. Change
npm ci→pnpm install --frozen-lockfileandnpm run build→pnpm buildin the install and build-app jobs. But the Dockerfile issue above still applies — keep the single-stage packaging Dockerfile regardless of which package manager you use.
docker-publish fails — "dist: not found" or "CopyIgnoredFile: dist excluded by .dockerignore"
CopyIgnoredFile: Attempting to Copy file "dist" that is excluded by .dockerignore (line 6)
ERROR: failed to build: failed to solve: failed to compute cache key: "/dist": not found
CopyIgnoredFile: Attempting to Copy file "dist" that is excluded by .dockerignore (line 6)
ERROR: failed to build: failed to solve: failed to compute cache key: "/dist": not found
The dist/ folder is listed in .dockerignore, so Docker strips it from the build context before the build starts. When the Dockerfile then tries to COPY dist/ /usr/share/nginx/html, there is nothing to copy.
Having dist in .dockerignore is normal practice for local development — it stops Docker from accidentally picking up a stale locally-compiled build. But in CI, dist/ is the artifact the build-app job specifically produced and that Docker needs to package.
Fix: Remove the dist line from .dockerignore:
# Open healthpulse-app/.dockerignore in your editor
# Delete the line that says: dist or dist/
# Save and commit
git add healthpulse-app/.dockerignore
git commit -m "fix: remove dist from .dockerignore for CI artifact packaging"
git push origin mainThe CI pipeline always produces a clean dist/ from the build-app job — removing it from .dockerignore is safe because Docker is never run locally with stale files in this workflow.
docker-publish fails — "lstat docker: no such file or directory"
ERROR: failed to build: resolve : lstat docker: no such file or directory
ERROR: failed to build: resolve : lstat docker: no such file or directory
Docker cannot find docker/Dockerfile because it is looking from the repo root, but your Dockerfile lives inside the app subfolder at healthpulse-app/docker/Dockerfile.
This job defines its own before_script (for docker login), so the default cd healthpulse-app does not run — the job always runs from the repo root.
Fix: Update the docker build command to prefix both the -f path and the build context with the subfolder:
# Before (fails if Dockerfile is in a subfolder):
- docker build -f docker/Dockerfile -t $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION .
# After (works with subfolder setup):
- docker build -f healthpulse-app/docker/Dockerfile -t $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION healthpulse-app/Setting the build context to healthpulse-app/ means the COPY dist/ and COPY docker/nginx.conf commands inside the Dockerfile resolve correctly without any changes to the Dockerfile itself.
docker-publish fails — "denied: requested access to the resource is denied"
denied: requested access to the resource is denied
denied: requested access to the resource is denied
The image was built successfully but the push was rejected. This almost always means the image tag is using $CI_REGISTRY instead of $CI_REGISTRY_IMAGE.
Check the image name logged before the push. If it looks like:
registry.gitlab.com/healthpulse-portal:10-abc1234
it is missing the namespace and project path. GitLab has no record of that registry path and rejects the push.
It should look like:
registry.gitlab.com/your-group/your-project/healthpulse-portal:10-abc1234
Fix: Replace $CI_REGISTRY with $CI_REGISTRY_IMAGE in all three docker commands:
script:
- docker build -f healthpulse-app/docker/Dockerfile -t $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION healthpulse-app/
- docker push $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION
- docker tag $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION $CI_REGISTRY_IMAGE/$APP_NAME:latest
- docker push $CI_REGISTRY_IMAGE/$APP_NAME:latest| Variable | Contains | Use for |
|---|---|---|
$CI_REGISTRY | registry.gitlab.com | docker login only |
$CI_REGISTRY_IMAGE | registry.gitlab.com/group/project | Image tags — build, push, pull |
docker-publish fails — image push rejected (wrong credentials)
denied: access forbidden
denied: access forbidden
Check that $CI_REGISTRY_PASSWORD is being passed correctly. The --password-stdin flag expects it from stdin, not as a positional argument:
- echo "${CI_REGISTRY_PASSWORD}" | docker login -u "${CI_REGISTRY_USER}" --password-stdin "${CI_REGISTRY}"image-scan fails — "unable to find image" / "UNAUTHORIZED" pulling from registry
FATAL run error: image scan error: unable to initialize artifact: unable to find the specified image
docker error: failed to connect to the docker API at unix:///var/run/docker.sock
remote error: GET https://registry.gitlab.com/v2/healthpulse-portal/manifests/...: UNAUTHORIZED
FATAL run error: image scan error: unable to initialize artifact: unable to find the specified image
docker error: failed to connect to the docker API at unix:///var/run/docker.sock
remote error: GET https://registry.gitlab.com/v2/healthpulse-portal/manifests/...: UNAUTHORIZED
Two things look alarming here but only one is the real problem:
The Docker socket errors are expected and harmless. Trivy tries local Docker first (/var/run/docker.sock), then containerd, then podman. On GitLab shared runners none of these are available. Trivy then falls back to pulling the image directly from the registry (remote mode). This is normal — ignore those socket errors.
The UNAUTHORIZED error is the real problem. Look at the registry path in the error:
registry.gitlab.com/v2/healthpulse-portal/manifests/...
The namespace is missing — it should be registry.gitlab.com/v2/your-group/your-project/healthpulse-portal/manifests/.... Trivy is attempting to pull an image path that doesn't exist in your registry.
Cause: The image-scan job is using $CI_REGISTRY/$APP_NAME instead of $CI_REGISTRY_IMAGE/$APP_NAME in the trivy command.
Fix:
image-scan:
script:
- trivy image
--exit-code 1
--severity HIGH,CRITICAL
--no-progress
--format table
--output trivy-results.txt
$CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION # ← not $CI_REGISTRYThe "no matching files" artifact warning is a consequence, not a separate problem. Trivy exits with an error before writing trivy-results.txt, so there is nothing to upload. Once the image reference is corrected and Trivy completes the scan, the file will be written and the artifact will upload.
image-scan fails — CVEs found in the image
INFO Detected OS family="alpine" version="3.21.3"
INFO [alpine] Detecting vulnerabilities... pkg_num=68
trivy-results.txt: found 1 matching artifact files ← scan completed and uploaded
ERROR: Job failed: exit code 1
INFO Detected OS family="alpine" version="3.21.3"
INFO [alpine] Detecting vulnerabilities... pkg_num=68
trivy-results.txt: found 1 matching artifact files ← scan completed and uploaded
ERROR: Job failed: exit code 1
This is the pipeline working correctly. Trivy scanned the image, found HIGH or CRITICAL CVEs in the base OS packages, and exited with code 1 as configured by --exit-code 1. The report was uploaded as an artifact.
This is what DevOps is for. CI caught a security issue in the base image before it reached any environment. On a real project, this becomes a ticket: "Upgrade nginx base image — Trivy found CVE-XXXX-XXXX (HIGH) in libssl3."
Step 1 — Read the report:
Go to the image-scan job → Browse (top right of job page) → download trivy-results.txt. It shows a table:
nginx:1.27-alpine (alpine 3.21.3)
Library Vulnerability Severity Installed Fixed Version
────────────────────────────────────────────────────────────────
libssl3 CVE-2024-XXXX HIGH 3.3.1-r0 3.3.2-r0
libcrypto3 CVE-2024-XXXX CRITICAL 3.3.1-r0 3.3.2-r0
Step 2 — Pick a resolution:
Option A — Update the base image (the correct fix):
# Use the latest nginx alpine to get newest OS security patches
FROM nginx:alpineCommit, push, let the pipeline rebuild and re-scan. If the CVE has a fixed version available, the newer patch release will include it.
Option B — Temporarily allow failures while the fix is being worked on:
image-scan:
allow_failure: true # CVEs found — acknowledged, fix tracked in ticket #XXOption C — Lower the severity gate if no fix exists yet (Trivy shows "fixed version: none"):
# Only block on CRITICAL, not HIGH — document the reason
- trivy image --exit-code 1 --severity CRITICAL
--no-progress --format table --output trivy-results.txt
$CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSIONAlways add a comment explaining why the threshold was changed and link to the tracking ticket.
image-scan fails — "unauthorized" pulling image from registry
Trivy cannot authenticate with the GitLab Container Registry. Check that TRIVY_USERNAME and TRIVY_PASSWORD are set correctly in the job variables:
variables:
TRIVY_USERNAME: $CI_REGISTRY_USER
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORDThese are GitLab-injected variables — they should always be available. If the job runs on an unprotected branch and your registry is set to "Private", confirm the runner has access to the project.
artifactory-upload fails — authentication error
- Verify
ARTIFACTORY_URL does not have a trailing slash - Verify the user has
deploy permissions on the repository in JFrog - Test the credentials manually:
curl -u $ARTIFACTORY_USER:$ARTIFACTORY_PASSWORD $ARTIFACTORY_URL/api/system/ping
ARTIFACTORY_URL does not have a trailing slashdeploy permissions on the repository in JFrogcurl -u $ARTIFACTORY_USER:$ARTIFACTORY_PASSWORD $ARTIFACTORY_URL/api/system/ping