Everything for GitLab CI/CD in one place — pick a section below. 26 reviewed items across 4 content types.
Quick Answer
`needs` turns a stage-ordered pipeline into a directed acyclic graph so jobs can start as soon as their dependencies finish. That improves speed, but correctness depends on explicitly declaring artifacts, optional dependencies, and rules so jobs do not start without the files or checks they truly require.
Detailed Answer
Think of an airport baggage system. In a simple airport, every bag moves through check-in, screening, sorting, and loading in one fixed sequence. That is safe but slow. A larger airport uses conveyor routes so bags can move as soon as their required step is done. GitLab CI needs works like those routes: it speeds flow by allowing jobs to bypass stage waiting, but the routes must still carry the right baggage.
Traditional GitLab pipelines execute by stages: all build jobs finish, then all test jobs, then deploy jobs. needs creates explicit job dependencies, letting a downstream job start early when its required upstream jobs finish. This is powerful for large monorepos, matrix builds, and platform pipelines where waiting for unrelated jobs wastes time. rules decide whether jobs exist in a given pipeline, while artifacts carry files between jobs.
Internally, when a job uses needs, GitLab no longer assumes it should download every artifact from previous stages. It downloads artifacts only from listed dependencies when configured. Optional needs handle jobs that may not exist because rules excluded them. Without optional dependencies, a pipeline can fail to create because a needed job is absent. With optional dependencies used carelessly, a deploy job can start without a security scan or environment package it needed.
At production scale, engineers use needs for fast feedback and clear ownership: service tests depend only on the service build, deploy depends on the exact image promotion job, and release notes depend on successful changelog generation. They watch queue time, critical path duration, artifact size, runner saturation, and failure-to-start errors. They also standardize reusable includes so teams do not handcraft fragile dependency graphs.
The non-obvious gotcha is that pipeline speed can hide missing quality gates. A job that starts earlier may be technically valid but operationally unsafe if it no longer waits for a cross-cutting compliance job. Senior engineers separate fast test feedback from release gates, use protected environments for deployment, make artifacts explicit, and model the pipeline as a graph with intentional safety barriers.
Code Example
gitlab-runner exec shell build-payments # Reproduces a build job locally when runner behavior is suspected. gitlab-ci-lint --filename .gitlab-ci.yml # Validates pipeline syntax before committing dependency graph changes. curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab.internal/api/v4/projects/42/pipelines/latest" # Checks the latest pipeline metadata for the payments-api project. curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab.internal/api/v4/projects/42/jobs?scope[]=failed" # Lists failed jobs to identify missing needs or artifact download issues. curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab.internal/api/v4/projects/42/pipelines/9001/dag" # Reviews the generated DAG so critical release gates are visible.
Interview Tip
A junior engineer typically answers that `needs` makes GitLab pipelines faster, but for a senior/architect role, the interviewer is actually looking for graph correctness. Explain that `needs` changes artifact download behavior, that rules can remove jobs, and that optional dependencies can be either necessary flexibility or a hidden bypass. A strong answer connects CI graph design to release safety, runner capacity, compliance gates, protected environments, and monorepo scaling. It should also separate fast feedback jobs from mandatory release gates.
◈ Architecture Diagram
┌──────────┐
│ Build │
└─┬─────┬──┘
↓ ↓
┌────┐ ┌────┐
│Test│ │Scan│
└─┬──┘ └─┬──┘
↓ ↓
┌──────────┐
│ Package │
└────┬─────┘
↓
┌──────────┐
│ Deploy │
└──────────┘💬 Comments
Quick Answer
Use rules:changes to trigger jobs only for modified paths, merge_request pipelines for PR validation, extends/includes for DRY configuration, and environment-specific stages with manual approval gates for production deployment.
Detailed Answer
Pipeline Architecture for Monorepos
Monorepo CI/CD requires selective execution—you don't want to rebuild the frontend when only Terraform files change. GitLab's rules:changes directive triggers jobs based on file path patterns. Combined with include:local for per-component pipeline definitions and extends for shared job templates, you can build a maintainable pipeline that scales with the repository. The pipeline should use a DAG (Directed Acyclic Graph) structure with needs keywords to maximize parallelism.
Merge Request Pipelines vs Branch Pipelines
Merge request pipelines (rules: - if: $CI_PIPELINE_SOURCE == 'merge_request_event') run only when an MR is opened or updated. They see both source and target branch, enabling accurate changes detection against the merge target. Branch pipelines (rules: - if: $CI_COMMIT_BRANCH) run on every push. Best practice: use MR pipelines for validation (lint, test, security scan) and branch pipelines only for deployment after merge. Avoid duplicate pipelines with workflow:rules to ensure only one pipeline type runs.
Environment Strategy
Define environments (dev, staging, production) using GitLab environments with deployment tracking. Staging deploys automatically on merge to main. Production requires manual approval using when: manual with allow_failure: false (creates a blocking gate). Use environment:auto_stop_in for review apps that automatically destroy after 24 hours. Protected environments restrict who can trigger production deployments to maintainers.
Caching and Artifact Strategy
Cache dependencies per-component using component-specific cache keys (e.g., $CI_PROJECT_DIR/backend/go.sum for Go modules). Artifacts pass build outputs between stages. Use artifacts:expire_in to prevent storage bloat—test results expire in 1 week, release binaries in 30 days. dependencies keyword controls which artifacts a job downloads, preventing unnecessary data transfer.
Security and Compliance Integration
Include GitLab's SAST/DAST templates for automated security scanning. Compliance pipelines (available in Ultimate) enforce required jobs that project teams cannot bypass. Use protected variables for production secrets, masked variables for tokens, and file-type variables for certificates. The pipeline should fail-fast on security issues while allowing teams to acknowledge and track accepted risks.
Code Example
# .gitlab-ci.yml - Monorepo pipeline
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
stages:
- validate
- build
- test
- security
- deploy-staging
- deploy-production
variables:
DOCKER_BUILDKIT: 1
GOFLAGS: "-mod=vendor"
# Shared templates
.docker-build:
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
.only-backend:
rules:
- changes:
- backend/**/*
- go.mod
- go.sum
.only-frontend:
rules:
- changes:
- frontend/**/*
- package.json
- yarn.lock
.only-infra:
rules:
- changes:
- terraform/**/*
# Backend jobs
backend:lint:
stage: validate
image: golangci/golangci-lint:v1.59
script:
- cd backend && golangci-lint run --timeout 5m
extends: .only-backend
cache:
key: go-lint-${CI_COMMIT_REF_SLUG}
paths: [backend/vendor/]
backend:build:
stage: build
extends: [.docker-build, .only-backend]
script:
- docker build -t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA backend/
- docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA
artifacts:
reports:
container_scanning: gl-container-scanning-report.json
backend:test:
stage: test
image: golang:1.22
extends: .only-backend
needs: ["backend:lint"]
script:
- cd backend && go test -race -coverprofile=coverage.out ./...
- go tool cover -func=coverage.out
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: backend/coverage.xml
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
# Frontend jobs
frontend:build:
stage: build
image: node:20-alpine
extends: .only-frontend
script:
- cd frontend && yarn install --frozen-lockfile && yarn build
artifacts:
paths: [frontend/dist/]
expire_in: 1 week
cache:
key: yarn-${CI_COMMIT_REF_SLUG}
paths: [frontend/node_modules/]
# Deployment
deploy:staging:
stage: deploy-staging
image: bitnami/kubectl:latest
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
environment:
name: staging
url: https://staging.example.com
script:
- kubectl set image deployment/backend backend=$CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA
- kubectl rollout status deployment/backend --timeout=300s
deploy:production:
stage: deploy-production
image: bitnami/kubectl:latest
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
environment:
name: production
url: https://app.example.com
allow_failure: false
needs: ["deploy:staging"]
script:
- kubectl set image deployment/backend backend=$CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA
- kubectl rollout status deployment/backend --timeout=300s
# Review app (per MR)
review:deploy:
stage: deploy-staging
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
environment:
name: review/$CI_MERGE_REQUEST_IID
url: https://$CI_MERGE_REQUEST_IID.review.example.com
auto_stop_in: 24 hours
on_stop: review:stop
script:
- helm upgrade --install review-$CI_MERGE_REQUEST_IID ./chart/ --set image.tag=$CI_COMMIT_SHA
review:stop:
stage: deploy-staging
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
when: manual
environment:
name: review/$CI_MERGE_REQUEST_IID
action: stop
script:
- helm uninstall review-$CI_MERGE_REQUEST_IIDInterview Tip
Emphasize the workflow:rules section that prevents duplicate pipelines—this is a common mistake. Show you understand the difference between changes detection in MR pipelines (compares to target branch) vs branch pipelines (compares to previous commit). The DAG structure with 'needs' is critical for large monorepos.
💬 Comments
Quick Answer
Deploy the GitLab Runner Operator on Kubernetes with autoscaling executors, use group runners for team-level shared infrastructure, project-specific runners for security-sensitive workloads, and implement runner tags for workload routing. Size based on job concurrency and resource profiles.
Detailed Answer
Runner Types and Scope
Shared runners serve all projects in a GitLab instance—convenient but create noisy neighbor problems and security concerns (a compromised job could access another project's secrets). Group runners serve all projects within a group—ideal for team-level shared infrastructure. Project-specific runners are dedicated to a single project—required for workloads processing sensitive data or requiring specialized hardware (GPU, ARM). In a 200-developer organization, use group runners as the default with project-specific runners for security-critical deployments.
Kubernetes Executor Architecture
The GitLab Runner Kubernetes executor creates a new pod for each CI job. The pod contains at minimum a build container (runs your script), a helper container (handles git clone and artifacts), and optionally service containers (postgres, redis for integration tests). Pods are created on-demand and destroyed after job completion, providing clean isolation and automatic scaling. Configure resource requests/limits per job using runner config or Kubernetes limit ranges.
Autoscaling Strategy
Use the GitLab Runner Helm chart with HPA (Horizontal Pod Autoscaler) based on the number of running jobs. The runner manager pod handles job acquisition; the Kubernetes executor handles pod creation. Set concurrent to control maximum parallel jobs per runner manager. Deploy multiple runner managers (3-5) for HA with anti-affinity rules. Use Kubernetes Cluster Autoscaler to add nodes when pod scheduling pressure increases—configure with appropriate scale-down delay to avoid thrashing.
Performance Optimization
The biggest performance bottleneck is dependency download. Solve with: distributed caching using S3/GCS-backed runner cache, Docker layer caching via a registry mirror or BuildKit cache mounts, and large PVC-backed workspace volumes for builds that reuse data. Use node pools with local SSDs for I/O-intensive builds. Pre-pull common base images using DaemonSets to eliminate image pull latency for frequent job types.
Security Isolation
Runner tags control which runners execute which jobs. Tag runners with their security level (e.g., production-deploy, untrusted-mr). Protected runners only execute jobs from protected branches/tags—essential for deployment jobs. Use Kubernetes namespaces with NetworkPolicies to isolate job pods from each other and from cluster services. For maximum isolation, use Kata Containers or gVisor runtime classes for untrusted workloads.
Code Example
# values.yaml for GitLab Runner Helm chart on Kubernetes
gitlabUrl: https://gitlab.com/
runnerToken: $RUNNER_TOKEN # From group settings
concurrent: 50 # Max parallel jobs per runner manager
checkInterval: 3 # Seconds between job polling
rbac:
create: true
clusterWideAccess: false
runners:
config: |
[[runners]]
name = "k8s-group-runner"
executor = "kubernetes"
[runners.kubernetes]
namespace = "gitlab-ci"
image = "ubuntu:22.04"
privileged = false
cpu_request = "500m"
cpu_limit = "2"
memory_request = "1Gi"
memory_limit = "4Gi"
service_cpu_request = "200m"
service_memory_request = "256Mi"
poll_timeout = 600
[runners.kubernetes.node_selector]
workload = "ci"
[runners.kubernetes.node_tolerations]
"ci-workload=true" = "NoSchedule"
[runners.kubernetes.pod_annotations]
"cluster-autoscaler.kubernetes.io/safe-to-evict" = "false"
[runners.cache]
Type = "gcs"
Path = "gitlab-ci-cache"
Shared = true
[runners.cache.gcs]
BucketName = "myorg-gitlab-ci-cache"
tags: "kubernetes,linux,amd64"
runUntagged: false
protected: false
# Node pool for CI workloads (GKE example)
gcloud container node-pools create ci-runners \
--cluster=platform-cluster \
--machine-type=n2-standard-8 \
--num-nodes=2 \
--enable-autoscaling --min-nodes=2 --max-nodes=20 \
--node-taints=ci-workload=true:NoSchedule \
--node-labels=workload=ci \
--local-ssd-count=1 \
--disk-type=pd-ssd \
--disk-size=100GB
# Deploy with Helm
helm repo add gitlab https://charts.gitlab.io
helm install --namespace gitlab-ci gitlab-runner gitlab/gitlab-runner \
-f values.yaml
# Monitor runner queue depth
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/runners?status=online" | jq '.[] | {id, description, active, contacted_at}'
# .gitlab-ci.yml using tagged runners
build:
tags:
- kubernetes
- linux
- amd64
image: golang:1.22
script:
- go build -o app ./cmd/server
# Security-sensitive deployment using protected project runner
deploy-production:
tags:
- production-deploy # Only on dedicated protected runner
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
script:
- kubectl apply -f k8s/production/Interview Tip
Quantify the problem: 200 developers x 5 pushes/day x 3 jobs/pipeline = 3000 jobs/day. Calculate required concurrency and runner capacity. Mention the distributed cache as the single biggest performance win after adding more runners. Security isolation via protected runners is a must-discuss for FAANG interviews.
💬 Comments
Quick Answer
Use needs keyword for DAG execution (skip stage ordering), parallel keyword for splitting test suites, distributed caching for dependencies, Docker layer caching with BuildKit, and rules to skip unnecessary jobs. Target: reduce from 25 min to under 8 min.
Detailed Answer
Identifying Bottlenecks
Before optimizing, analyze the pipeline using GitLab's pipeline analytics (CI/CD > Pipelines > Charts) and job duration data. Common bottlenecks: sequential stage execution when jobs are independent, downloading dependencies every run, full test suite running serially, and Docker builds without layer caching. Enable CI_DEBUG_TRACE on slow jobs to identify I/O vs CPU bottlenecks.
DAG Execution with needs Keyword
By default, GitLab runs all jobs in a stage only after the previous stage completes. The needs keyword breaks this constraint—a job starts immediately when its dependencies finish, regardless of stage. Example: Docker build can start as soon as unit tests pass, without waiting for the SAST scan in the same stage. This alone can save 30-50% of pipeline time by overlapping independent work.
Parallel Test Execution
The parallel keyword splits a job into N instances. Combined with test splitting tools (like split-tests for Go or jest --shard for JavaScript), you distribute the test suite across parallel runners. For a 15-minute test suite, parallel: 5 reduces it to ~3 minutes plus overhead. GitLab provides $CI_NODE_INDEX and $CI_NODE_TOTAL variables for custom splitting logic.
Caching Strategy
Cache dependency directories (node_modules, vendor, .gradle) with keys based on lock files. Use cache:policy:pull for jobs that only read cache and push for jobs that update it. Distribute cache via S3/GCS for multi-runner setups—local filesystem caching doesn't work when jobs land on different runners. Set cache:when: always to update cache even on job failure, preventing stale caches.
Docker Build Optimization
Use BuildKit with --cache-from pointing to the registry for layer caching. Multi-stage builds with strategic layer ordering (dependencies before source code) maximize cache hits. For projects with complex builds, use kaniko or buildx with registry-backed cache. Enable Docker BuildKit's inline cache export: --build-arg BUILDKIT_INLINE_CACHE=1. This single optimization often saves 5-10 minutes on Docker-heavy pipelines.
Code Example
# Optimized pipeline with DAG, caching, and parallelism
stages:
- validate
- build
- test
- package
- deploy
variables:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
# Lint runs independently, no dependencies
lint:
stage: validate
image: golangci/golangci-lint:v1.59
script:
- golangci-lint run --timeout 5m
cache:
key: lint-$CI_COMMIT_REF_SLUG
paths: [.cache/golangci-lint/]
policy: pull-push
# Build binary (needed by tests and Docker build)
build:
stage: build
image: golang:1.22
script:
- go build -o app ./cmd/server
artifacts:
paths: [app]
expire_in: 1 hour
cache:
key:
files: [go.sum]
paths: [vendor/]
policy: pull-push
# Parallel unit tests - split into 4 shards
unit-test:
stage: test
image: golang:1.22
needs: ["build"] # Start immediately after build, don't wait for lint
parallel: 4
script:
- |
# Split test packages across parallel instances
ALL_PKGS=$(go list ./... | grep -v /integration/)
PKGS=$(echo "$ALL_PKGS" | awk "NR % $CI_NODE_TOTAL == $CI_NODE_INDEX - 1")
go test -race -count=1 -coverprofile=coverage-$CI_NODE_INDEX.out $PKGS
artifacts:
paths: [coverage-*.out]
reports:
junit: report.xml
cache:
key:
files: [go.sum]
paths: [vendor/]
policy: pull
# Integration tests (needs build, runs parallel to unit tests)
integration-test:
stage: test
image: golang:1.22
needs: ["build"]
services:
- postgres:16
- redis:7
variables:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
DB_HOST: postgres
script:
- go test -tags=integration -timeout 300s ./tests/integration/...
# Docker build with layer caching (runs as soon as build completes)
docker-build:
stage: package
needs: ["build", "unit-test"] # Starts after build + tests, parallel with integration
image: docker:24
services:
- docker:24-dind
script:
- |
docker buildx create --use
docker buildx build \
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE:cache \
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max \
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
--push .
# SAST scan (independent, runs parallel to everything after validate)
sast:
stage: test
needs: [] # No dependencies, starts immediately
include:
- template: Security/SAST.gitlab-ci.yml
# Deploy only needs docker-build, doesn't wait for SAST
deploy:
stage: deploy
needs: ["docker-build", "integration-test"]
script:
- kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
environment:
name: staging
# Result: Pipeline visualization
# lint ────────────────────────────────────────────┐
# build ──┬── unit-test (x4) ──┬── docker-build ──── deploy
# ├── integration-test ─┘ │
# └── sast ────────────────────────────────────┘
# Total time: max(lint, build + max(unit-test, integration) + docker-build + deploy)
# ≈ max(2min, 1min + max(3min, 4min) + 2min + 1min) = 8min vs 25min sequentialInterview Tip
Draw the DAG on a whiteboard showing parallel execution paths. Calculate the critical path (longest sequential chain) to show the theoretical minimum time. The cache key based on lock files is a detail that shows you have optimized real pipelines. Always mention measuring before optimizing.
💬 Comments
Quick Answer
Use trigger keyword with project path to spawn downstream pipelines, pass variables via the trigger block, use strategy:depend to wait for downstream completion, and implement bridge jobs for fan-in. Use API triggers for complex orchestration patterns.
Detailed Answer
Multi-Project Pipeline Architecture
In microservice architectures, a shared library change can break downstream services. Multi-project pipelines let you trigger pipelines in other projects when a library is updated. The upstream pipeline creates bridge jobs that spawn downstream pipelines, optionally waiting for their completion before proceeding. This ensures integration compatibility is validated before the library is published.
Trigger Mechanisms
GitLab provides two trigger approaches: the trigger keyword (declarative, in .gitlab-ci.yml) and the Pipeline API (imperative, for complex orchestration). The trigger keyword is simpler—you specify the downstream project, branch, and variables. The downstream pipeline runs with its own .gitlab-ci.yml but receives the passed variables. strategy: depend makes the bridge job wait for the downstream pipeline to complete and mirrors its status.
Variable Passing and Artifact Sharing
Pass variables from upstream to downstream using the variables block within trigger. For artifact sharing across projects, use the needs:project keyword—a downstream job can fetch artifacts from a specific job in the upstream pipeline. This enables patterns like: library builds a package, downstream services download and test against it. Use CI_JOB_TOKEN for authentication between projects in the same group.
Fan-Out/Fan-In Pattern
Fan-out: a single upstream job triggers multiple downstream project pipelines in parallel. Each bridge job is independent and starts simultaneously. Fan-in: after all downstream pipelines complete (using strategy:depend on each bridge job), a final upstream job runs to aggregate results or publish the library. This pattern validates that a library change doesn't break any consumer before release.
Production Considerations
Limit downstream triggers to avoid CI resource exhaustion—a library used by 50 services shouldn't trigger 50 full pipelines on every commit. Use lightweight smoke test pipelines in downstream projects specifically for cross-project validation. Implement circuit breakers: if a downstream project's main branch is already broken, skip triggering it. Store pipeline status in a shared state (Redis or GitLab API) for complex orchestration beyond what YAML can express.
Code Example
# Platform library .gitlab-ci.yml (upstream)
stages:
- build
- test
- trigger-downstream
- publish
build-library:
stage: build
script:
- go build ./...
- go install ./...
artifacts:
paths: [dist/]
test-library:
stage: test
script:
- go test ./...
# Fan-out: trigger all consumer services
trigger-service-a:
stage: trigger-downstream
trigger:
project: myorg/services/service-a
branch: main
strategy: depend # Wait for downstream to complete
variables:
UPSTREAM_LIBRARY_VERSION: $CI_COMMIT_SHA
UPSTREAM_PIPELINE_ID: $CI_PIPELINE_ID
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
trigger-service-b:
stage: trigger-downstream
trigger:
project: myorg/services/service-b
branch: main
strategy: depend
variables:
UPSTREAM_LIBRARY_VERSION: $CI_COMMIT_SHA
UPSTREAM_PIPELINE_ID: $CI_PIPELINE_ID
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
trigger-service-c:
stage: trigger-downstream
trigger:
project: myorg/services/service-c
branch: main
strategy: depend
variables:
UPSTREAM_LIBRARY_VERSION: $CI_COMMIT_SHA
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# Fan-in: only publish if ALL downstream services pass
publish-library:
stage: publish
needs:
- trigger-service-a
- trigger-service-b
- trigger-service-c
script:
- echo "All consumers validated, publishing library"
- go-module-proxy publish v$CI_COMMIT_TAG
rules:
- if: $CI_COMMIT_TAG
# --- Downstream service .gitlab-ci.yml (service-a) ---
stages:
- prepare
- test
- report
# Download library artifact from upstream when triggered
prepare:
stage: prepare
script:
- |
if [ -n "$UPSTREAM_LIBRARY_VERSION" ]; then
echo "Testing against library commit: $UPSTREAM_LIBRARY_VERSION"
go get github.com/myorg/platform-lib@$UPSTREAM_LIBRARY_VERSION
fi
- go mod tidy && go mod vendor
integration-test:
stage: test
needs: ["prepare"]
script:
- go test -tags=integration ./...
artifacts:
reports:
junit: report.xml
# API trigger for complex orchestration (from script)
# curl --request POST \
# --form token=$TRIGGER_TOKEN \
# --form ref=main \
# --form "variables[LIBRARY_SHA]=$CI_COMMIT_SHA" \
# "https://gitlab.com/api/v4/projects/12345/trigger/pipeline"Interview Tip
Explain the business case: shared library breaking downstream services is a real production problem. The fan-out/fan-in pattern with strategy:depend is the key architecture. Mention resource management—you need to prevent trigger storms from exhausting CI capacity.
💬 Comments
Quick Answer
Include GitLab security scanning templates (SAST, Container Scanning, DAST, Dependency Scanning), configure security policies to require approval for critical vulnerabilities, use merge request approval rules tied to scan results, and implement compliance frameworks for enforcement.
Detailed Answer
GitLab Security Scanning Overview
GitLab provides built-in security scanners integrated directly into CI/CD: SAST (static code analysis), DAST (dynamic application testing), Container Scanning (CVE detection in images), Dependency Scanning (vulnerable libraries), Secret Detection (leaked credentials), and License Compliance. These produce standardized JSON reports that populate the Security Dashboard and merge request widgets.
SAST Integration
GitLab's SAST uses multiple analyzers based on language (Semgrep for multi-language, gosec for Go, bandit for Python). Include the template and it auto-detects languages. For monorepos, customize SAST_EXCLUDED_PATHS to avoid scanning generated code. Custom rulesets via .gitlab/sast-ruleset.toml can disable noisy rules or add organization-specific patterns. SAST runs on merge request diffs, showing only new findings introduced by the change.
Container Scanning and DAST
Container Scanning uses Trivy to analyze Docker images for OS and application vulnerabilities. Configure severity thresholds to fail the pipeline on critical/high CVEs. DAST runs against a deployed application (typically staging), performing automated penetration testing. DAST requires a running URL—use review apps or a dedicated staging environment. DAST profiles can be customized for authenticated scanning to test behind login pages.
Security Policies and Gates
Security policies (Ultimate tier) enforce scan execution and approval requirements without relying on project maintainers. A scan execution policy requires specific scans to run on every pipeline—teams cannot remove or modify them. A merge request approval policy requires security team approval when vulnerabilities of specified severity are detected. This creates a hard gate: code with critical vulnerabilities cannot merge without explicit security team sign-off.
Operationalizing Security Findings
The Vulnerability Report tracks all findings across projects. Vulnerabilities can be dismissed (with justification), confirmed, or resolved. Create issues directly from vulnerabilities for tracking. For large organizations, use the Security Dashboard at group level to see aggregate risk. Implement SLAs: critical vulnerabilities must be fixed within 48 hours, high within 1 week, medium within 1 sprint.
Code Example
# .gitlab-ci.yml with full security scanning
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
- template: Security/Container-Scanning.gitlab-ci.yml
- template: DAST.gitlab-ci.yml
variables:
# SAST configuration
SAST_EXCLUDED_PATHS: "vendor/,node_modules/,generated/"
SAST_EXCLUDED_ANALYZERS: "eslint" # Use semgrep instead
SEARCH_MAX_DEPTH: 10
# Container scanning
CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
CS_SEVERITY_THRESHOLD: HIGH # Fail on HIGH or CRITICAL
# DAST
DAST_WEBSITE: https://$CI_MERGE_REQUEST_IID.review.example.com
DAST_FULL_SCAN_ENABLED: "false" # Use passive scan for MRs
DAST_BROWSER_SCAN: "true"
stages:
- build
- test
- deploy-review
- dast
- deploy-staging
- deploy-production
# Override container scanning to depend on docker build
container_scanning:
needs: ["docker-build"]
variables:
CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# DAST against review app
dast:
stage: dast
needs: ["deploy-review"]
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
variables:
DAST_WEBSITE: https://$CI_MERGE_REQUEST_IID.review.example.com
DAST_AUTH_URL: https://$CI_MERGE_REQUEST_IID.review.example.com/login
DAST_USERNAME: $DAST_TEST_USER
DAST_PASSWORD: $DAST_TEST_PASS
DAST_USERNAME_FIELD: "css:#email"
DAST_PASSWORD_FIELD: "css:#password"
DAST_SUBMIT_FIELD: "css:button[type=submit]"
# Custom SAST rules - .gitlab/sast-ruleset.toml
# [semgrep]
# [[semgrep.passthrough]]
# type = "file"
# target = "custom-rules.yml"
# value = ".gitlab/semgrep-rules.yml"
# [[semgrep.ruleset]]
# [semgrep.ruleset.disable]
# rules = ["go.lang.security.audit.dangerous-exec-command"]
# Scan execution policy (applied at group level via GitLab UI or API)
# scan-execution-policy.yml
name: Enforce Security Scans
description: Require SAST and container scanning on all projects
enabled: true
rules:
- type: pipeline
branches:
- main
- feature/*
actions:
- scan: sast
- scan: container_scanning
- scan: secret_detection
# Merge request approval policy
# approval-policy.yml
name: Security Approval Required
description: Require security team approval for critical vulnerabilities
enabled: true
rules:
- type: scan_finding
branches:
- main
scanners:
- container_scanning
- sast
vulnerabilities_allowed: 0
severity_levels:
- critical
- high
actions:
- type: require_approval
approvals_required: 1
user_approvers:
- security-lead
group_approvers:
- myorg/security-teamInterview Tip
Differentiate between security scanning (finding issues) and security policies (enforcing gates). Mention that SAST in MR pipelines shows only new findings, reducing noise. The compliance framework and scan execution policies show you understand enterprise security governance.
💬 Comments
Quick Answer
GitLab CI/CD offers the most integrated DevSecOps platform (SCM + CI + CD + security + monitoring in one). GitHub Actions excels in marketplace ecosystem and GitHub-native workflows. Jenkins provides maximum flexibility and plugin extensibility but highest operational overhead. Choose based on existing SCM, security requirements, and team size.
Detailed Answer
Architecture and Philosophy Comparison
GitLab CI/CD is deeply integrated with GitLab's source control, issue tracking, container registry, and security scanning—everything in one platform with a unified UI. GitHub Actions is event-driven with a massive marketplace of community actions, tightly integrated with GitHub's ecosystem (Dependabot, CodeQL, Copilot). Jenkins is a self-hosted automation server with 1800+ plugins, maximum customization but zero managed infrastructure—you own everything.
Setup and Maintenance Cost
GitLab CI: Zero setup on GitLab.com (shared runners included). Self-managed requires Omnibus installation and runner fleet management. GitHub Actions: Zero setup on github.com (2000 free minutes/month). Self-hosted runners for private repos or custom environments. Jenkins: Full self-management—install, configure, upgrade, manage plugins, handle security patches. A Jenkins cluster requires 0.5-1 FTE for maintenance at scale. TCO for 200 developers: GitLab Ultimate ~$24K/year, GitHub Enterprise ~$21K/year, Jenkins ~$50K/year (infra + engineer time).
CI/CD Capabilities Deep Dive
GitLab has native environments, review apps, feature flags, and release management. Its pipeline syntax (YAML) supports includes, extends, rules, and DAG natively. GitHub Actions uses workflow YAML with matrix strategies, reusable workflows, and composite actions. Its major advantage is the marketplace—need to deploy to any cloud? There's probably a well-maintained action. Jenkins uses Groovy-based Jenkinsfiles with shared libraries, offering the most powerful scripting but steepest learning curve.
Security and Compliance
GitLab Ultimate includes SAST, DAST, container scanning, dependency scanning, and compliance frameworks—no external tools needed. GitHub has CodeQL (SAST), Dependabot (dependency), and secret scanning built-in, plus marketplace security actions. Jenkins requires integrating external tools (SonarQube, Trivy, OWASP ZAP) via plugins, each needing configuration and maintenance. For regulated industries, GitLab's compliance pipelines and audit events provide the strongest out-of-box governance.
Scalability and Performance
All three scale to thousands of concurrent jobs. GitLab and GitHub's SaaS offerings scale transparently. Self-hosted: GitLab runners auto-scale on Kubernetes or cloud VMs. GitHub self-hosted runners need custom autoscaling (actions-runner-controller). Jenkins scales via controller/agent architecture with cloud plugins (EC2, Kubernetes). At FAANG scale, all three work—the differentiator is operational burden, not capability ceiling.
Code Example
# COMPARISON: Same pipeline across all three platforms
# --- GitLab CI (.gitlab-ci.yml) ---
stages: [build, test, deploy]
build:
stage: build
image: golang:1.22
script:
- go build -o app ./cmd/server
artifacts:
paths: [app]
test:
stage: test
image: golang:1.22
script:
- go test -race ./...
coverage: '/coverage: (\d+\.\d+)%/'
deploy:
stage: deploy
image: bitnami/kubectl
environment: production
when: manual
script:
- kubectl set image deploy/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# --- GitHub Actions (.github/workflows/ci.yml) ---
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go build -o app ./cmd/server
- uses: actions/upload-artifact@v4
with:
name: binary
path: app
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go test -race ./...
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBE_CONFIG }}
- run: kubectl set image deploy/app app=ghcr.io/${{ github.repository }}:${{ github.sha }}
# --- Jenkins (Jenkinsfile) ---
pipeline {
agent { kubernetes { yaml kubePodTemplate() } }
stages {
stage('Build') {
steps {
container('golang') {
sh 'go build -o app ./cmd/server'
archiveArtifacts artifacts: 'app'
}
}
}
stage('Test') {
steps {
container('golang') {
sh 'go test -race ./...'
}
}
}
stage('Deploy') {
when { branch 'main' }
input { message 'Deploy to production?' }
steps {
container('kubectl') {
sh "kubectl set image deploy/app app=${env.REGISTRY}:${env.GIT_COMMIT}"
}
}
}
}
}Interview Tip
Don't just list features—frame the comparison around organizational context. A startup with 10 engineers on GitHub should use Actions. An enterprise with compliance requirements and 500 engineers benefits from GitLab Ultimate. A legacy shop with 200 Jenkinsfiles should migrate incrementally. Show you can make contextual recommendations.
💬 Comments
Quick Answer
Tag images with commit SHA for immutability, semantic versions for releases, and environment labels for promotion. Implement container scanning in CI, use cleanup policies for untagged images, and promote by retagging (not rebuilding) when promoting across environments.
Detailed Answer
Image Tagging Strategy
Never use latest in production—it's mutable and breaks reproducibility. Use commit SHA ($CI_COMMIT_SHA or short SHA) as the primary immutable tag for every build. Add semantic version tags on release branches/tags. Add environment tags (staging, production) that are mutable pointers to the currently deployed SHA. This gives you both immutability (SHA) and human-readable references (version, environment).
Build Once, Promote Everywhere
A critical principle: build the image exactly once in CI, then promote it across environments by adding tags. Never rebuild for different environments—this ensures the exact bytes tested in staging are what runs in production. Use environment-specific configuration via ConfigMaps/Secrets, not baked into the image. The promotion step is simply: pull by SHA, tag with new environment, push.
Container Scanning Integration
GitLab's Container Scanning (powered by Trivy) runs after image build and produces a vulnerability report. Configure severity thresholds: fail the pipeline on CRITICAL/HIGH, warn on MEDIUM. The scan results appear in the merge request security widget and the project vulnerability report. For base image vulnerabilities, use multi-stage builds with distroless or Alpine base images to minimize attack surface.
Garbage Collection and Cleanup
GitLab Container Registry accumulates images rapidly in active projects. Configure cleanup policies: keep images matching semver tags indefinitely, keep last 10 images per branch, delete untagged manifests older than 7 days. For self-managed GitLab, run registry garbage collection as a cron job. Monitor registry storage to prevent disk exhaustion—a common cause of GitLab outages.
Multi-Architecture Builds
For organizations running both AMD64 and ARM64 (e.g., AWS Graviton), use Docker buildx with manifest lists to build multi-arch images. A single tag resolves to the correct architecture at pull time. This adds build time but simplifies deployment manifests.
Code Example
# .gitlab-ci.yml - Container lifecycle management
variables:
REGISTRY: $CI_REGISTRY_IMAGE
IMAGE_SHA: $REGISTRY:$CI_COMMIT_SHA
IMAGE_BRANCH: $REGISTRY:$CI_COMMIT_REF_SLUG
stages:
- build
- scan
- deploy-staging
- promote
- deploy-production
# Build and push with multiple tags
docker-build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- |
docker buildx build \
--tag $IMAGE_SHA \
--tag $IMAGE_BRANCH \
--label org.opencontainers.image.revision=$CI_COMMIT_SHA \
--label org.opencontainers.image.source=$CI_PROJECT_URL \
--push .
# Tag with semver if this is a release tag
- |
if [ -n "$CI_COMMIT_TAG" ]; then
docker buildx build \
--tag $REGISTRY:$CI_COMMIT_TAG \
--tag $REGISTRY:latest \
--push .
fi
# Container scanning
container_scanning:
stage: scan
needs: ["docker-build"]
variables:
CS_IMAGE: $IMAGE_SHA
CS_SEVERITY_THRESHOLD: HIGH
include:
- template: Security/Container-Scanning.gitlab-ci.yml
# Deploy to staging (uses SHA tag)
deploy-staging:
stage: deploy-staging
script:
- kubectl set image deployment/app app=$IMAGE_SHA -n staging
- kubectl rollout status deployment/app -n staging --timeout=300s
environment:
name: staging
# Promote: retag staging image for production (NO rebuild)
promote-to-production:
stage: promote
image: gcr.io/go-containerregistry/crane:latest
needs: ["deploy-staging"]
when: manual
script:
- |
# Use crane to copy/retag without pulling full image
crane auth login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
crane tag $IMAGE_SHA production
echo "Promoted $CI_COMMIT_SHA to production tag"
environment:
name: production
# Deploy production (uses same SHA, just validates promotion happened)
deploy-production:
stage: deploy-production
needs: ["promote-to-production"]
script:
- kubectl set image deployment/app app=$IMAGE_SHA -n production
- kubectl rollout status deployment/app -n production --timeout=300s
environment:
name: production
# --- Cleanup policy (Settings > Packages & Registries > Cleanup) ---
# Or via API:
# curl --request PUT \
# --header "PRIVATE-TOKEN: $TOKEN" \
# --header "Content-Type: application/json" \
# --data '{
# "container_expiration_policy_attributes": {
# "enabled": true,
# "cadence": "7d",
# "keep_n": 10,
# "older_than": "14d",
# "name_regex_delete": ".*",
# "name_regex_keep": "(main|production|v\\d+\\.\\d+\\.\\d+)"
# }
# }' \
# "https://gitlab.com/api/v4/projects/$PROJECT_ID"
# Self-managed: Registry garbage collection
# sudo gitlab-ctl registry-garbage-collect -m # Remove unreferenced manifestsInterview Tip
The 'build once, promote everywhere' principle is the most important concept. Explain why rebuilding is dangerous (different base image layers, different dependency versions between builds). Mention crane/skopeo for efficient image operations without pulling full layers.
💬 Comments
Quick Answer
Create a central CI/CD templates repository with reusable job definitions, use include:project for cross-project includes, extends for job inheritance, and migrate to GitLab CI/CD Components (catalog) for versioned, discoverable pipeline building blocks with defined inputs.
Detailed Answer
The DRY Problem in GitLab CI
When organizations grow, teams copy-paste CI/CD configurations, leading to drift, inconsistent security practices, and maintenance nightmares. A fix to the Docker build process needs updating in 30 files. The solution is a layered approach: YAML anchors for intra-file reuse, extends for job inheritance, include for cross-file composition, and CI/CD Components for versioned, cataloged building blocks.
Include Strategies
include:project pulls YAML from another repository—ideal for a central templates repo. include:remote pulls from any URL (useful for open-source templates). include:template uses GitLab's built-in templates. include:local references files within the same project. Templates can be overridden by the including project—jobs defined in includes are merged with local definitions, with local values taking precedence.
CI/CD Components (GitLab 17+)
Components are the evolution of includes—they provide versioned, documented, discoverable pipeline building blocks with declared inputs and outputs. Published to the CI/CD Catalog (like a marketplace), they have semantic versioning, input validation, and usage documentation. Components solve the versioning problem that raw includes have: you can pin to @1.2.3 and upgrade deliberately rather than being broken by upstream changes.
Migration Strategy
Phase 1: Identify common patterns across projects (Docker build, deploy to K8s, security scanning). Phase 2: Extract into a templates repository using include:project with ref pinning. Phase 3: Add versioning via Git tags on the templates repo. Phase 4: Migrate to CI/CD Components for better discoverability and input validation. Each phase is backward-compatible—projects can migrate incrementally.
Governance and Standards
The platform team owns the templates repository with branch protection and code review requirements. Changes go through a testing pipeline that validates templates against sample projects. Use compliance pipelines (Ultimate) to enforce that certain includes cannot be overridden—ensuring security scanning always runs regardless of project-level configuration.
Code Example
# Central templates repository: ci-templates/docker-build.yml
# Reusable Docker build template with configurable inputs
.docker-build-template:
image: docker:24
services:
- docker:24-dind
variables:
DOCKERFILE_PATH: Dockerfile
BUILD_CONTEXT: .
DOCKER_BUILDKIT: 1
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- |
docker buildx build \
--file $DOCKERFILE_PATH \
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG \
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE:cache \
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max \
--push \
$BUILD_CONTEXT
# Central templates repository: ci-templates/deploy-k8s.yml
.deploy-k8s-template:
image: bitnami/kubectl:latest
variables:
K8S_NAMESPACE: default
ROLLOUT_TIMEOUT: 300s
script:
- kubectl config use-context $KUBE_CONTEXT
- kubectl set image deployment/$APP_NAME $APP_NAME=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n $K8S_NAMESPACE
- kubectl rollout status deployment/$APP_NAME -n $K8S_NAMESPACE --timeout=$ROLLOUT_TIMEOUT
# --- Consumer project .gitlab-ci.yml ---
include:
- project: 'platform/ci-templates'
ref: 'v2.1.0' # Pin to version!
file:
- '/docker-build.yml'
- '/deploy-k8s.yml'
- template: Security/SAST.gitlab-ci.yml
stages:
- build
- test
- deploy
# Inherit and customize
docker-build:
stage: build
extends: .docker-build-template
variables:
DOCKERFILE_PATH: build/Dockerfile.production
BUILD_CONTEXT: .
deploy-staging:
stage: deploy
extends: .deploy-k8s-template
variables:
K8S_NAMESPACE: staging
APP_NAME: my-service
KUBE_CONTEXT: staging-cluster
environment:
name: staging
# --- CI/CD Component (GitLab 17+) ---
# templates/docker-build/template.yml
spec:
inputs:
stage:
default: build
dockerfile:
default: Dockerfile
context:
default: .
registry:
default: $CI_REGISTRY_IMAGE
---
"docker-build-$[[ inputs.stage ]]":
stage: $[[ inputs.stage ]]
image: docker:24
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker buildx build --file $[[ inputs.dockerfile ]] --tag $[[ inputs.registry ]]:$CI_COMMIT_SHA --push $[[ inputs.context ]]
# Consumer using CI/CD Component
include:
- component: gitlab.com/platform/ci-templates/[email protected]
inputs:
dockerfile: build/Dockerfile
stage: buildInterview Tip
Frame this as a platform engineering problem, not just YAML refactoring. The key insight is versioning—without pinned refs, template changes break all consumers simultaneously. CI/CD Components with the catalog are the future direction; showing you know this demonstrates you stay current with GitLab's roadmap.
💬 Comments
Quick Answer
Use dynamic environments with CI_MERGE_REQUEST_IID for unique naming, deploy per-MR Helm releases with isolated namespaces or resource prefixes, configure auto_stop_in for automatic cleanup, and implement environment:on_stop jobs that tear down all provisioned resources.
Detailed Answer
Review App Architecture
Review apps provide isolated preview environments for each merge request, enabling reviewers to test changes without deploying to shared staging. Each review app gets a unique URL (e.g., mr-123.review.example.com), its own database instance or schema, and independent scaling. GitLab tracks these as dynamic environments with automatic lifecycle management.
Dynamic Environment Naming
Use environment: name: review/$CI_MERGE_REQUEST_IID to create unique environments per MR. The URL uses wildcard DNS (*.review.example.com) pointing to an ingress controller, with per-deployment ingress rules routing to the correct service. Each environment appears in GitLab's Environments page with a direct link to the running app and the associated MR.
Resource Isolation Strategy
Two approaches: namespace-per-review (stronger isolation, cleaner cleanup) or shared-namespace with prefixed resources. Namespace-per-review creates a Kubernetes namespace per MR with its own RBAC, network policies, and resource quotas. This prevents one review app from consuming excessive resources and simplifies cleanup (delete the namespace). Database isolation can use PostgreSQL schemas, dedicated Cloud SQL instances (expensive), or ephemeral containers.
Automatic Cleanup
auto_stop_in: 24 hours triggers the on_stop job after 24 hours of inactivity. The on_stop job must clean up ALL resources: Helm release, namespace, DNS records, database, and any cloud resources. Without proper cleanup, review apps accumulate and exhaust cluster resources. Implement a safety net: a scheduled pipeline that finds orphaned review app resources older than 48 hours and deletes them.
Cost Management
Review apps can be expensive if each gets full-size resources. Use minimal resource requests (100m CPU, 128Mi memory), single-replica deployments, and shared infrastructure (one PostgreSQL instance with per-review schemas). Set resource quotas on review namespaces. For expensive dependencies (ML models, large databases), use shared mocked services or read-only replicas.
Code Example
# .gitlab-ci.yml - Review apps with full lifecycle
variables:
REVIEW_NAMESPACE: review-mr-$CI_MERGE_REQUEST_IID
REVIEW_URL: https://mr-$CI_MERGE_REQUEST_IID.review.example.com
HELM_RELEASE: review-$CI_MERGE_REQUEST_IID
stages:
- build
- deploy
- cleanup
# Deploy review app
review:deploy:
stage: deploy
image: alpine/helm:3.14
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
environment:
name: review/$CI_MERGE_REQUEST_IID
url: $REVIEW_URL
auto_stop_in: 24 hours
on_stop: review:stop
before_script:
- kubectl create namespace $REVIEW_NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
- kubectl label namespace $REVIEW_NAMESPACE app.kubernetes.io/part-of=review-apps --overwrite
script:
- |
helm upgrade --install $HELM_RELEASE ./chart/ \
--namespace $REVIEW_NAMESPACE \
--set image.tag=$CI_COMMIT_SHA \
--set image.repository=$CI_REGISTRY_IMAGE \
--set ingress.host=mr-$CI_MERGE_REQUEST_IID.review.example.com \
--set database.name=review_mr_$CI_MERGE_REQUEST_IID \
--set resources.requests.cpu=100m \
--set resources.requests.memory=128Mi \
--set resources.limits.cpu=500m \
--set resources.limits.memory=512Mi \
--set replicaCount=1 \
--wait --timeout 300s
# Run database migrations
- kubectl exec -n $REVIEW_NAMESPACE deploy/$HELM_RELEASE -- ./migrate up
- echo "Review app deployed at $REVIEW_URL"
# Stop/cleanup review app
review:stop:
stage: cleanup
image: alpine/helm:3.14
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
when: manual
variables:
GIT_STRATEGY: none # Don't need source code for cleanup
environment:
name: review/$CI_MERGE_REQUEST_IID
action: stop
script:
- helm uninstall $HELM_RELEASE --namespace $REVIEW_NAMESPACE || true
- kubectl delete namespace $REVIEW_NAMESPACE --wait=false || true
# Cleanup database
- |
kubectl exec -n shared-services deploy/postgres-admin -- \
psql -c "DROP DATABASE IF EXISTS review_mr_$CI_MERGE_REQUEST_IID;"
- echo "Review app cleaned up"
# --- chart/templates/ingress.yaml ---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Release.Name }}-tls
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}
port:
number: 8080
# --- Orphan cleanup scheduled pipeline (runs daily) ---
# cleanup-orphans.gitlab-ci.yml
orphan-cleanup:
rules:
- if: $CI_PIPELINE_SOURCE == 'schedule'
script:
- |
# Find namespaces older than 48 hours with no running MR
for ns in $(kubectl get ns -l app.kubernetes.io/part-of=review-apps -o name); do
ns_name=$(echo $ns | cut -d/ -f2)
mr_id=$(echo $ns_name | grep -oP '\d+')
# Check if MR is still open
state=$(curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/merge_requests/$mr_id" | jq -r '.state')
if [ "$state" != "opened" ]; then
echo "Cleaning orphaned review app: $ns_name (MR state: $state)"
kubectl delete namespace $ns_name
fi
doneInterview Tip
The orphan cleanup scheduled pipeline is what separates a POC from production. Every review app implementation leaks resources without it. Mention cost management (minimal resources) and the wildcard DNS + cert-manager pattern. Show you have thought about database isolation—it is the hardest part of review apps.
💬 Comments
Quick Answer
Use GitLab's pipeline graph visualization to identify the critical path, convert stage-based execution to DAG with needs keyword, eliminate unnecessary artifact downloads with dependencies keyword, use resource_group for deployment serialization, and implement parallel with test splitting.
Detailed Answer
Diagnosing Pipeline Bottlenecks
GitLab's pipeline visualization shows job dependencies and execution timeline. The critical path is the longest sequential chain of jobs—this is your pipeline's minimum possible duration. Jobs outside the critical path can be optimized but won't reduce total time. Use CI/CD Analytics to see median durations over time and identify regressions. The job log's Preparing executor time reveals runner acquisition delays separate from actual execution time.
DAG vs Stage-Based Execution
Default GitLab behavior: all jobs in stage N must complete before any job in stage N+1 starts. This is wasteful when jobs have no data dependencies. The needs keyword creates a DAG: a job starts as soon as its declared dependencies complete, regardless of stage. Example: SAST scanning (no build dependency) can start immediately, while deploy (depends on build + test) waits only for those specific jobs. Stage names become purely organizational.
Artifact Optimization
By default, all jobs in a stage download all artifacts from the previous stage—even if they don't need them. dependencies: [] prevents downloading any artifacts. dependencies: [job-name] downloads only from specified jobs. For DAG pipelines, needs implicitly sets dependencies. Large artifacts (Docker images, compiled binaries) add significant transfer time; use artifacts:expire_in aggressively and consider registry storage for images instead of CI artifacts.
Resource Groups for Deployment
resource_group ensures only one job with that group name runs at a time—essential for deployments where two simultaneous deploys would conflict. Unlike stages, resource_group works across pipelines: if pipeline A is deploying, pipeline B's deploy job queues until A finishes. This prevents race conditions without blocking the entire pipeline.
Pipeline Efficiency Metrics
Track: pipeline duration (wall clock), total job time (sum of all job durations), and efficiency ratio (total time / duration / concurrent jobs). A 30-minute pipeline with 60 minutes of total job time running 10 concurrent jobs has poor efficiency—most jobs are waiting. Aim for the critical path to be less than 20% of total job time, indicating good parallelization.
Code Example
# Before optimization: 40-minute sequential pipeline
# After optimization: 12-minute DAG pipeline
stages:
- validate # 3 min total
- build # 5 min total
- test # 15 min total
- security # 8 min total
- package # 4 min total
- deploy # 3 min total
# Jobs with explicit DAG dependencies
lint:
stage: validate
needs: [] # Start immediately, no dependencies
script: golangci-lint run
schema-validate:
stage: validate
needs: [] # Parallel with lint
script: openapi-generator validate -i api/openapi.yaml
build-binary:
stage: build
needs: ["lint"] # Only needs lint, not schema-validate
script: go build -o app ./cmd/server
artifacts:
paths: [app]
expire_in: 2 hours
build-frontend:
stage: build
needs: [] # Frontend doesn't depend on Go lint
script: cd frontend && npm ci && npm run build
artifacts:
paths: [frontend/dist/]
expire_in: 2 hours
# Parallel test execution
unit-test:
stage: test
needs: ["build-binary"] # Starts as soon as binary builds
parallel: 4
script:
- PACKAGES=$(go list ./... | split-tests -n $CI_NODE_TOTAL -i $CI_NODE_INDEX)
- go test -race $PACKAGES
artifacts:
reports:
junit: report-$CI_NODE_INDEX.xml
integration-test:
stage: test
needs: ["build-binary"] # Parallel with unit tests!
services:
- postgres:16
script: go test -tags=integration ./tests/...
frontend-test:
stage: test
needs: ["build-frontend"] # Only depends on frontend build
dependencies: ["build-frontend"] # Only download frontend artifacts
script: cd frontend && npm test
# Security scans run parallel to tests (no build dependency)
sast:
stage: security
needs: [] # Start at pipeline begin!
include:
- template: Security/SAST.gitlab-ci.yml
dependency-scan:
stage: security
needs: [] # Parallel with everything
include:
- template: Security/Dependency-Scanning.gitlab-ci.yml
# Docker build only needs binary + frontend
docker-build:
stage: package
needs: ["build-binary", "build-frontend"]
dependencies: ["build-binary", "build-frontend"] # Only these artifacts
script:
- docker buildx build --push --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
# Deploy with resource_group prevents concurrent deploys
deploy-staging:
stage: deploy
needs: ["docker-build", "unit-test", "integration-test"]
dependencies: [] # No artifacts needed for deploy
resource_group: staging-deploy # Serialize across pipelines
environment:
name: staging
script:
- kubectl set image deploy/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# Critical path: lint(1m) -> build(2m) -> unit-test(4m) -> docker-build(3m) -> deploy(2m) = 12 min
# vs sequential: 3+5+15+8+4+3 = 38 min
# Pipeline efficiency check (run as scheduled job)
pipeline-metrics:
rules:
- if: $CI_PIPELINE_SOURCE == 'schedule'
script:
- |
curl -s --header "PRIVATE-TOKEN: $TOKEN" \
"https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/pipelines?per_page=20&status=success" | \
jq '[.[] | {id, duration: .duration, created: .created_at}] |
{avg_duration: (map(.duration) | add / length / 60 | round),
count: length}'Interview Tip
Always calculate and state the critical path explicitly. The interviewer wants to see that you understand the theoretical minimum pipeline time and how close your optimization gets to it. The before/after comparison (38 min -> 12 min) with specific numbers is very compelling.
💬 Comments
Quick Answer
In .gitlab-ci.yml, as jobs grouped into stages that run sequentially; jobs in the same stage run in parallel.
Detailed Answer
Each job declares a stage, a script, and optional rules/needs. Stages run in order (build -> test -> deploy) and jobs within a stage run in parallel. needs: creates a DAG so a job can start before its whole stage finishes, speeding pipelines.
Code Example
stages: [build, test] build: stage: build script: [make]
Interview Tip
Mention needs: to build a DAG instead of strict stages.
💬 Comments
Quick Answer
Runners are the agents that execute jobs; executors (docker, shell, kubernetes) define how each job runs.
Detailed Answer
A runner picks up jobs and runs them via an executor. The docker executor runs each job in a fresh container (clean, reproducible); the kubernetes executor spins up a pod per job; shell runs directly on the host (least isolated). Tags match jobs to specific runners.
Interview Tip
Prefer docker/kubernetes executors for isolation.
💬 Comments
Quick Answer
rules is the modern, expressive way to include/exclude jobs by branch, changes, or variables; only/except is the legacy form.
Detailed Answer
rules evaluates conditions (if, changes, exists) top-down and the first match wins, setting when: on_success/manual/never. It replaces the older only/except. Use rules to run deploys only on main, or skip jobs when unrelated paths change.
Code Example
deploy:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'Interview Tip
Recommend rules over only/except in new configs.
💬 Comments
Quick Answer
cache speeds up repeat runs by reusing directories keyed on a lockfile; artifacts hand files from one job to later stages.
Detailed Answer
cache is for reusable, non-essential data (package caches) keyed by e.g. the lockfile hash. artifacts are outputs (build results, reports) that downstream jobs depend on and that appear in the UI. Use dependencies:/needs: to control which artifacts a job pulls.
Interview Tip
cache = optimization, artifacts = required outputs.
💬 Comments
Quick Answer
Protected branches restrict who can push/merge; protected/masked variables limit secret exposure; environments track and gate deployments.
Detailed Answer
Mark production secrets as protected (only available on protected branches) and masked (hidden in logs). environment: production records deploys, enables manual approval, and supports rollback. Together they keep prod credentials and deploys locked down.
Interview Tip
Protected + masked variables is the secret-handling answer.
💬 Comments
Context
A self-managed GitLab with three static VM runners: 10+ minute job queues at peak, idle burn overnight, and every team fighting for the same runner tags.
Problem
Static capacity can't follow the 10am-6pm demand curve; heavyweight E2E jobs starved quick lint jobs; and one runner's disk filling with Docker layers took a third of capacity down repeatedly.
Solution
Deployed gitlab-runner on Kubernetes with the kubernetes executor and three runner classes exposed as tags: `small` (1CPU/2Gi, lint/unit), `build` (4CPU/8Gi with BuildKit and registry layer cache), and `heavy` (8CPU/16Gi, E2E with ephemeral service containers). Cluster autoscaler + spot node pools handle the curve; each job runs in a fresh pod (no state bleed, no disk rot). Concurrency limits per class stop E2E from starving small jobs; interruptible: true lets superseded pipelines cancel on new pushes.
Commands
helm install gitlab-runner gitlab/gitlab-runner --set runners.executor=kubernetes
job config: {tags: [build], interruptible: true}default: {interruptible: true} # kill superseded pipelinesOutcome
P95 queue time fell from 11 minutes to 40 seconds; compute cost dropped 35% (scale-to-near-zero nights/weekends, spot nodes); the disk-rot outage class disappeared with ephemeral pods.
Lessons Learned
Runner classes as tags gave teams a vocabulary for cost ('does this really need heavy?'); interruptible pipelines saved more compute than the autoscaler — cancelling superseded work is free money.
💬 Comments
Context
A regulated fintech on self-managed GitLab where each team owned its .gitlab-ci.yml — and audits kept finding projects that had quietly deleted or skipped the mandated SAST/dependency-scanning jobs when they slowed pipelines down.
Problem
Security jobs enforced by convention get removed under deadline pressure; the security team was re-auditing 80 projects quarterly by hand, and two incidents shipped through pipelines whose scan jobs had been commented out.
Solution
Moved enforcement into the platform: a compliance framework applied via group-level settings attaches a compliance pipeline configuration that runs before/alongside project YAML and cannot be overridden from the repo — scan jobs, license checks, and an image-provenance verification job execute regardless of project config. Project pipelines keep full freedom for their own stages; the compliance jobs report to security dashboards and block merge via approval rules on findings above threshold. Rollout went framework-by-group with a two-week report-only mode per group before enforcement.
Commands
Group -> Compliance frameworks -> pipeline configuration: compliance/.compliance-ci.yml@security/pipelines
include: project YAML still owned by teams; compliance jobs injected by framework
approval rule: 'security-scan' status check required for merge
Outcome
Scan coverage went from 'audited quarterly, ~70% honest' to 100% by construction; quarterly manual audits retired; the report-only rollout surfaced (and fixed) the three projects whose builds genuinely conflicted with scanner defaults before enforcement flipped.
Lessons Learned
Enforcement by platform beats enforcement by review — anything a repo can edit, deadline pressure will edit. Report-only mode first is what kept engineering goodwill; enforcement landing with known-clean pipelines was a non-event.
💬 Comments
Context
A product team whose frontend changes were reviewed by screenshot: designers and PMs couldn't run branches locally, so visual/UX feedback arrived after merge — as rework tickets.
Problem
Feedback landed post-merge (10x the cost), QA validated on a shared staging that was perpetually mid-deploy from other branches, and 'works on staging' meant nothing attributable to a specific MR.
Solution
Added review apps to the pipeline: each MR deploys to an ephemeral namespace (helm install per MR with a wildcard DNS host mr-<id>.review.example.com), GitLab's environment integration surfaces the live URL directly on the MR, seeded with anonymized fixture data. environment:on_stop plus auto_stop_in: 3 days reaps abandoned apps; a nightly job garbage-collects namespaces orphaned by force-pushes. Resource quotas cap the review cluster so a busy week degrades to queueing, not cluster exhaustion.
Commands
deploy_review:
environment:
name: review/$CI_MERGE_REQUEST_IID
url: https://mr-$CI_MERGE_REQUEST_IID.review.example.com
on_stop: stop_review
auto_stop_in: 3 dayshelm upgrade --install mr-$CI_MERGE_REQUEST_IID ./chart -n review-$CI_MERGE_REQUEST_IID
Outcome
Design/PM feedback moved pre-merge (review-app link is now the review ritual); post-merge visual rework tickets dropped ~80%; QA validates the exact MR in isolation. Review cluster cost stays flat at roughly two staging environments' worth.
Lessons Learned
auto_stop_in plus the orphan reaper is mandatory — without both, the cluster fills with zombie namespaces in a month. Anonymized-but-realistic seed data determined whether non-engineers actually used the apps.
💬 Comments
Symptom
A deployment failed after a developer viewed manual pipeline variables and copied a token into a debug job.
Root Cause
The project enabled visibility for manual pipeline variables, and a production credential was passed as a plain pipeline variable. GitLab documentation warns that manual variable visibility and protected variables must be treated carefully. The pipeline worked, but the secret handling model was wrong. The underlying issue was a combination of factors that individually seemed harmless but together created a cascading failure. The monitoring that should have caught this early was either missing or configured with thresholds too high to trigger before user impact. The on-call engineer initially pursued the wrong hypothesis because the symptoms resembled a different, more common failure mode. By the time the real root cause was identified, the blast radius had expanded beyond the original service to affect downstream dependencies. This incident exposed a gap in the team's runbooks and highlighted the need for better correlation between metrics, logs, and traces during diagnosis.
Diagnosis Steps
Solution
Revoke the exposed token, move production credentials to protected variables or external secrets, and limit production jobs to protected branches and protected runners. Prefer typed pipeline inputs for non-secret deployment choices.
Commands
Rotate the deployment token in the cloud provider
Update GitLab protected CI/CD variable
Rerun deployment from a protected branch
Prevention
Audit manual pipeline variable settings. Use protected variables and runners. Keep secrets out of manually entered variables whenever possible.
◈ Architecture Diagram
┌──────────┐
│ Trigger │
└────┬─────┘
↓
┌──────────┐
│ Incident │
└────┬─────┘
↓
┌──────────┐
│ Verify │
└──────────┘💬 Comments
Symptom
A deployment job started failing after a CI template refactor. The job reached Vault but received `401 unauthorized` when requesting secrets, while older jobs from the same project still worked.
Error Message
Vault OIDC login failed: audience claim did not match any bound audiences
Root Cause
The refactor removed the job-specific `id_tokens` configuration and the token audience no longer matched Vault's expected audience. GitLab ID tokens are JWTs with claims such as issuer, subject, audience, and expiration. Third-party services can and should reject tokens whose `aud` claim does not match the configured bound audience.
Diagnosis Steps
Solution
Restore explicit `id_tokens` configuration with the expected audience for each external service. Keep separate token variables for separate services rather than reusing one broad token. Update Vault or cloud provider bindings only if the intended trust relationship changed.
Commands
echo "$VAULT_ID_TOKEN" | cut -d '.' -f2 | base64 -d | jq .
git diff HEAD~1 -- .gitlab-ci.yml
vault read auth/jwt/role/deploy-prod
Prevention
Make OIDC token audiences part of the CI contract. Add template tests or lint checks that fail if deployment jobs omit required `id_tokens` blocks.
◈ Architecture Diagram
GitLab job ↓ JWT aud Vault role ↓ mismatch 401 unauthorized
💬 Comments
Symptom
Intermittent, unreproducible build failures across many projects: dependency archives unpack corrupt ('unexpected end of file'), but retrying the job sometimes succeeds. Local builds are always fine. Failures cluster on large caches and busy hours.
Error Message
tar: Unexpected EOF in archive; ERROR: Job failed: exit code 2 — following 'Restoring cache from s3://gitlab-runner-cache/...'
Root Cause
The runner cache bucket had an S3 lifecycle rule (added during a cost cleanup) that aborted 'incomplete multipart uploads' after 1 day — but the threshold interacted badly with the runner's multipart cache uploads under load: slow large-cache uploads that legitimately spanned the cleanup boundary were aborted mid-write, leaving truncated objects that the cache key still referenced. Subsequent jobs restored the truncated archive. The corruption was intermittent because only caches written during slow-upload windows were affected.
Diagnosis Steps
Solution
Fixed the lifecycle rule (7-day abort threshold, excluded the runner-cache prefix from aggressive cleanup), purged all cache objects to reset to a clean state, and enabled cache-key versioning salt so the purge was atomic from the runners' perspective. Added checksum validation to the cache restore wrapper (fail-and-rebuild rather than use-corrupt).
Commands
aws s3api head-object --bucket gitlab-runner-cache --key project/x/cache.zip # size anomaly
aws s3api get-bucket-lifecycle-configuration --bucket gitlab-runner-cache
runner config: cache: {s3: {...}}; wrapper: sha256sum -c cache.sum || rm -rf cache/Prevention
Treat CI cache storage as production infrastructure: lifecycle/cleanup changes to CI buckets go through the same review as app-data buckets. Validate archives on restore (cheap) so corruption degrades to a cache miss instead of a mystery failure. Alert on cache-restore failure rate as an early-warning metric.
💬 Comments
Symptom
A nightly data-refresh pipeline and a weekly dependency-update pipeline simply stop running. No failures, no alerts — the pipelines aren't red, they're absent. Discovered ten days later when stale data reaches a customer report.
Error Message
No error. Schedule page shows the schedules 'inactive'; audit log: owner account deactivated during offboarding; GitLab deactivates schedules owned by blocked/removed users.
Root Cause
Scheduled pipelines run with their owner's identity and permissions. Offboarding deactivated the departing engineer's account, which deactivated every schedule they owned across a dozen projects. Nothing alerted because a schedule that doesn't fire produces no failed pipeline — monitoring watched for red pipelines, not missing ones. Ownership had never been inventoried; several other schedules were one departure away from the same fate.
Diagnosis Steps
Solution
Re-created the affected schedules under a dedicated bot/service account with project-scoped tokens, backfilled the missed runs, and inventoried all schedules org-wide (API sweep) to migrate personal-owned ones to service accounts. Added absence monitoring: each scheduled pipeline's last-success timestamp is exported, and a freshness alert fires if any exceeds its expected cadence.
Commands
glab api projects/:id/pipeline_schedules | jq '.[] | {id, description, active, owner: .owner.username}'for p in $(glab api groups/:gid/projects --paginate | jq '.[].id'); do glab api projects/$p/pipeline_schedules; done
promql: time() - gitlab_schedule_last_success_timestamp > expected_interval
Prevention
Scheduled/automation pipelines belong to service accounts, never humans — make it a policy checked in the offboarding runbook AND a periodic audit (list schedules by owner type). Monitor for absence, not just failure: every cron-like job needs a 'did it run?' freshness check independent of its success status.
💬 Comments