Everything for GitLab in one place — pick a section below. 62 reviewed items across 5 content types.
Quick Answer
Parent-child pipelines use the trigger keyword with include to dynamically generate and run downstream pipelines as child pipelines of the current pipeline. The parent pipeline creates child pipelines that appear nested in the UI, share the same project context, and can pass variables and artifacts between them. They are ideal for monorepos and dynamically generated pipeline configurations.
Detailed Answer
Think of parent-child pipelines like a general contractor managing a home renovation. The general contractor (parent pipeline) does not personally install the plumbing, electrical, and cabinetry. Instead, they hire specialized subcontractors (child pipelines) for each trade, hand them the blueprints (variables and artifacts), and coordinate the overall project. Each subcontractor works independently, has their own team and schedule, but reports back to the general contractor who decides whether the renovation is complete. If the plumber finds a problem, the general contractor can halt the electrician before they start work.
Parent-child pipelines are created when a job in the parent pipeline uses the trigger keyword with include to reference a YAML file that defines the child pipeline's configuration. The child pipeline runs within the same project but as a separate pipeline instance with its own jobs, stages, and status. The parent job that triggered the child pipeline reflects the child's status: if the child pipeline fails, the parent trigger job also fails (unless strategy: depend is not set, in which case the parent job succeeds immediately after triggering). You can have up to two levels of nesting (a child can trigger a grandchild), and a parent can trigger multiple children. Child pipeline configurations can be dynamically generated by a preceding job, where a script creates a YAML file and the trigger job uses artifact to reference it. This dynamic generation is the killer feature that enables truly flexible CI/CD architectures.
Internally, when GitLab encounters a trigger job with include, it reads the referenced YAML file, parses it as a complete pipeline configuration, creates a new pipeline record linked to the parent pipeline as a child, and begins executing its jobs independently. The child pipeline has its own pipeline ID, its own set of stages, and its own Runner assignments. The parent pipeline's trigger job enters a waiting state (if strategy: depend is set) and monitors the child's status. Variables can be passed from parent to child using the variables keyword in the trigger block, and artifacts from the parent pipeline's jobs can be consumed by the child if the trigger job specifies needs or the artifacts are from previous stages. The child pipeline appears nested under the parent in the GitLab UI's pipeline view, providing a clear visual hierarchy. Pipeline status propagation ensures that the overall parent pipeline reflects the worst status of any child.
In production, parent-child pipelines shine in monorepo architectures. Consider a company with a commerce-platform repository containing frontend/, api/, worker/, and infrastructure/ directories. The parent pipeline has a detect-changes job that runs a script to determine which directories have modified files. It then generates child pipeline YAML dynamically based on the changes. If only frontend/ files changed, only the frontend child pipeline runs, skipping api/, worker/, and infrastructure/ entirely. This can reduce a 45-minute full-pipeline run to a 10-minute targeted run. A real implementation at a company like Shopify or GitLab itself uses this pattern to handle hundreds of components in a single repository, where running all possible jobs on every commit would consume enormous Runner capacity and developer time. The parent pipeline serves as the orchestrator, and each child pipeline is a self-contained build-test-deploy sequence for its component.
A critical gotcha is the artifact passing behavior between parent and child pipelines. By default, child pipelines do not automatically receive artifacts from the parent. You must explicitly pass variables and ensure the child pipeline's jobs reference the parent job's artifacts via needs:pipeline. Another common mistake is not setting strategy: depend on the trigger job, which causes the parent to show success even if the child fails, breaking the pipeline status chain. Also, be aware of the depth limit: GitLab allows a maximum of two levels of pipeline nesting (parent -> child -> grandchild). Attempting to trigger a pipeline from a grandchild will fail. For complex orchestration beyond two levels, use multi-project pipelines instead.
Code Example
# Parent pipeline: .gitlab-ci.yml
stages:
- detect # Detect which components changed
- generate # Generate child pipeline configs dynamically
- trigger # Trigger child pipelines
- deploy # Final deployment stage
# Detect changes in the monorepo
detect-changes:
stage: detect # Assign to detect stage
image: alpine:3.19 # Lightweight image
script:
- apk add --no-cache git # Install git for diff detection
- |
# Determine which directories have changes compared to main
CHANGED_DIRS=$(git diff --name-only origin/main...HEAD | cut -d'/' -f1 | sort -u)
echo "Changed directories: $CHANGED_DIRS" # Log detected changes
echo "CHANGED_DIRS=$CHANGED_DIRS" >> detect.env # Save to dotenv artifact
artifacts:
reports:
dotenv: detect.env # Export variables to downstream jobs
# Dynamically generate child pipeline config based on changes
generate-frontend-pipeline:
stage: generate # Assign to generate stage
image: alpine:3.19 # Lightweight image
needs:
- detect-changes # Depends on change detection
script:
- |
# Generate child pipeline YAML dynamically
cat > frontend-pipeline.yml << 'YAML'
stages:
- build
- test
build-frontend:
stage: build
image: node:20-alpine
script:
- cd frontend && npm ci && npm run build
artifacts:
paths:
- frontend/dist/
test-frontend:
stage: test
image: node:20-alpine
script:
- cd frontend && npm ci && npm run test
YAML
artifacts:
paths:
- frontend-pipeline.yml # Save generated YAML for trigger job
rules:
- if: $CI_COMMIT_BRANCH # Run on any branch
changes:
- frontend/** # Only when frontend files changed
# Trigger the frontend child pipeline
trigger-frontend:
stage: trigger # Assign to trigger stage
trigger:
include:
- artifact: frontend-pipeline.yml # Use dynamically generated config
job: generate-frontend-pipeline # From this job's artifacts
strategy: depend # Parent waits for child to complete
needs:
- generate-frontend-pipeline # Depends on the generated config
rules:
- if: $CI_COMMIT_BRANCH # Run on any branch
changes:
- frontend/** # Only when frontend files changed
# Trigger the API child pipeline using a static YAML file
trigger-api:
stage: trigger # Assign to trigger stage
trigger:
include:
- local: .gitlab-ci/api-pipeline.yml # Static child pipeline config
strategy: depend # Parent waits for child to complete
variables:
API_VERSION: $CI_COMMIT_SHA # Pass variable to child pipeline
rules:
- if: $CI_COMMIT_BRANCH # Run on any branch
changes:
- api/** # Only when API files changed
# Final deployment after all children succeed
deploy-platform:
stage: deploy # Assign to deploy stage
script:
- echo "All components built and tested" # Log success
- kubectl apply -f k8s/production/ # Deploy all components
environment:
name: production # Track production deployment
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only deploy from main
when: manual # Require manual triggerInterview Tip
A junior engineer typically describes pipelines as a flat sequence of stages and does not know about parent-child pipelines. Demonstrate advanced expertise by explaining the trigger keyword with include, the dynamic pipeline generation pattern where a preceding job creates the child YAML from a script, and the strategy: depend flag for status propagation. Describe the monorepo use case where change detection determines which child pipelines to launch, dramatically reducing unnecessary CI work. Mention the two-level nesting limit, the artifact passing nuances, and how the UI renders child pipelines nested under the parent. This shows you can architect CI/CD for complex, multi-component projects rather than just writing simple linear pipelines.
💬 Comments
Quick Answer
Multi-project pipelines trigger pipelines in other GitLab projects using the trigger keyword with a project path. Unlike parent-child pipelines (which run within the same project), multi-project pipelines cross project boundaries, enabling cross-repository orchestration for microservice deployments, shared library validation, and release coordination across independent services.
Detailed Answer
Think of multi-project pipelines like a supply chain across different factories. The car factory (project A) needs engines from the engine factory (project B) and transmissions from the transmission factory (project C). When the car factory starts a new production run (pipeline), it sends orders (triggers) to both the engine and transmission factories. Each factory runs its own manufacturing process independently, but the car factory waits for the parts to arrive (downstream pipelines to complete) before final assembly. Unlike parent-child pipelines (which are departments within the same factory), multi-project pipelines coordinate across completely separate facilities with their own managers, budgets, and processes.
Multi-project pipelines are created using the trigger keyword with a project path instead of an include. When a job specifies trigger: project: 'group/other-project', GitLab creates a new pipeline in the target project on the specified branch (defaulting to the target project's default branch). The trigger job in the source project can pass variables to the downstream pipeline and optionally wait for it to complete using strategy: depend. The downstream pipeline runs with the target project's .gitlab-ci.yml and its own Runners, variables, and permissions. This is fundamentally different from parent-child pipelines: parent-child pipelines run within the same project context, share the same project permissions, and appear nested in the UI. Multi-project pipelines run in separate projects, have independent permissions, and appear as linked pipelines in the UI with arrows showing the triggering relationship.
Internally, when GitLab processes a multi-project trigger job, it uses the CI_JOB_TOKEN of the triggering job to authenticate against the target project's pipeline creation API. This requires that the source project has been granted the trigger pipelines permission in the target project's CI/CD settings (Settings > CI/CD > Pipeline triggers or the newer CI/CD > Job token permissions). The downstream pipeline is created with a reference back to the upstream pipeline, enabling the linked visualization in the UI. Variables passed through the trigger are injected as pipeline-level variables in the downstream project, following the standard variable precedence rules. If strategy: depend is set, the upstream trigger job polls the downstream pipeline's status and mirrors it. The upstream pipeline's overall status then depends on the downstream pipeline, creating a cross-project dependency chain.
In production, multi-project pipelines are essential for microservice architectures where services live in separate repositories. Consider a ride-sharing platform with separate projects: rider-app, driver-app, matching-engine, payment-service, and api-gateway. When the shared-libraries project pushes a new version, its pipeline triggers downstream pipelines in all consuming services to verify backward compatibility. Similarly, when matching-engine is updated, its pipeline triggers integration tests in rider-app and driver-app because they depend on the matching engine's API. A release coordinator pipeline in a dedicated release-orchestrator project triggers deployments across all services in the correct order: first deploy matching-engine, then api-gateway, then rider-app and driver-app in parallel. This cross-project orchestration ensures that interdependent services are deployed together and validated as a system.
A key gotcha is the authentication and permission model. By default, CI_JOB_TOKEN can only trigger pipelines in projects that have explicitly allowed it. If you get a 403 error when triggering a downstream pipeline, check the target project's CI/CD settings to ensure the source project is in the allow list. Another common issue is variable collision: if you pass a variable like DEPLOY_ENV from the upstream pipeline, it might conflict with a variable of the same name defined in the downstream project's settings, and the precedence rules determine which wins. Unlike parent-child pipelines, multi-project pipelines cannot share artifacts directly; you need to use the GitLab API to download artifacts from the upstream pipeline or publish them to the package registry. Also, there is no nesting depth limit for multi-project pipelines (project A can trigger B which triggers C which triggers D), but long chains create fragile dependency graphs that are hard to debug.
Code Example
# Source project: services/matching-engine/.gitlab-ci.yml
stages:
- build # Build the matching engine
- test # Test the matching engine
- publish # Publish the API contract
- downstream # Trigger downstream service tests
# Build the matching engine
build-engine:
stage: build # Assign to build stage
image: golang:1.22 # Use Go image
script:
- go build -o matching-engine ./cmd/ # Compile the engine
artifacts:
paths:
- matching-engine # Save the binary
# Run unit tests
test-engine:
stage: test # Assign to test stage
image: golang:1.22 # Use Go image
script:
- go test ./... -v -race # Run tests with race detection
# Publish API contract to package registry
publish-contract:
stage: publish # Assign to publish stage
script:
- |
# Upload OpenAPI spec to GitLab package registry
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
--upload-file api/openapi.yaml \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/generic/api-contract/$CI_COMMIT_SHA/openapi.yaml"
# Trigger integration tests in the rider-app project
trigger-rider-app:
stage: downstream # Assign to downstream stage
trigger:
project: services/rider-app # Target project path
branch: main # Trigger on the main branch
strategy: depend # Wait for downstream pipeline to complete
variables:
MATCHING_ENGINE_VERSION: $CI_COMMIT_SHA # Pass version to downstream
UPSTREAM_PROJECT_ID: $CI_PROJECT_ID # Pass project ID for artifact download
RUN_INTEGRATION_TESTS: "true" # Signal to run integration test suite
# Trigger integration tests in the driver-app project
trigger-driver-app:
stage: downstream # Assign to downstream stage
trigger:
project: services/driver-app # Target project path
branch: main # Trigger on the main branch
strategy: depend # Wait for downstream pipeline to complete
variables:
MATCHING_ENGINE_VERSION: $CI_COMMIT_SHA # Pass version to downstream
RUN_INTEGRATION_TESTS: "true" # Signal to run integration tests
# ---------------------------------------------------
# Downstream project: services/rider-app/.gitlab-ci.yml
# ---------------------------------------------------
stages:
- build # Build the rider app
- test # Run tests including integration tests
build-rider-app:
stage: build # Assign to build stage
image: node:20-alpine # Use Node.js image
script:
- npm ci # Install dependencies
- npm run build # Build the application
# Integration tests triggered by upstream matching-engine
integration-tests:
stage: test # Assign to test stage
image: node:20-alpine # Use Node.js image
script:
- |
# Download matching engine artifact from upstream project
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
-o matching-engine \
"$CI_API_V4_URL/projects/$UPSTREAM_PROJECT_ID/packages/generic/api-contract/$MATCHING_ENGINE_VERSION/openapi.yaml"
- npm run test:integration # Run integration tests against upstream API
rules:
- if: $RUN_INTEGRATION_TESTS == "true" # Only run when triggered by upstream
- if: $CI_MERGE_REQUEST_IID # Also run for merge requests in this projectInterview Tip
A junior engineer typically conflates parent-child and multi-project pipelines or does not know the difference. Demonstrate advanced understanding by clearly contrasting them: parent-child runs within the same project with nested UI display and shared permissions, while multi-project triggers pipelines across separate repositories with independent permissions and linked UI display. Explain the CI_JOB_TOKEN authentication model and the allow list configuration required for cross-project triggers. Discuss real use cases like shared library validation (triggering consumers when a library changes) and coordinated microservice deployments. Mention the artifact sharing limitation and how to work around it with package registries.
💬 Comments
Quick Answer
Compliance frameworks in GitLab label projects with regulatory requirements (SOC2, HIPAA, PCI-DSS), and compliance pipelines inject mandatory CI/CD jobs that project maintainers cannot bypass or remove. This ensures security scans, audit logging, and approval gates run on every pipeline regardless of what individual teams configure in their .gitlab-ci.yml.
Detailed Answer
Think of compliance frameworks like building codes enforced by a city inspector. A homeowner (developer) can design their house (pipeline) however they want: open floor plan, three bedrooms, rooftop garden. But the city (compliance team) requires every house to have smoke detectors, fire exits, and earthquake-resistant foundations. The building inspector (compliance pipeline) adds these non-negotiable elements to every construction project automatically. The homeowner cannot remove the smoke detectors just because they find them inconvenient. The inspector's checklist (compliance pipeline configuration) is maintained by the city, not the homeowner.
Compliance frameworks are labels applied to GitLab projects that indicate which regulatory or organizational standards they must adhere to. Available in GitLab Ultimate, frameworks are created at the group level and assigned to projects. Each framework can have an associated compliance pipeline configuration stored in a separate, locked-down project. When a project has a compliance framework assigned, GitLab automatically injects the compliance pipeline's jobs into every pipeline run for that project. These injected jobs run alongside the project's own jobs but cannot be modified, skipped, or removed by the project's developers or maintainers. The compliance pipeline configuration is maintained by a dedicated compliance or security team with access to the compliance template project, creating a clear separation of duties.
Internally, when a pipeline is created for a project with a compliance framework, GitLab fetches the compliance pipeline configuration from the template project specified in the framework. It merges this configuration with the project's own .gitlab-ci.yml, with the compliance configuration taking precedence for jobs with the same name. The merged configuration is evaluated as a single pipeline, with compliance jobs appearing alongside the project's own jobs. The compliance pipeline configuration uses the same YAML syntax as regular .gitlab-ci.yml files, so it can define jobs, stages, rules, and variables. Because the compliance configuration is stored in a separate project, only users with access to that project can modify it, and changes to the compliance configuration are tracked through merge requests with their own approval rules. This ensures that compliance requirements are versioned, auditable, and protected from circumvention.
In production, a healthcare company subject to HIPAA would create a compliance framework named HIPAA and assign it to all projects handling protected health information. The compliance pipeline configuration, stored in a compliance/pipeline-templates project, defines mandatory jobs: a SAST scan using GitLab's security scanning templates, a dependency scan for known vulnerabilities, a secret detection scan, an audit log job that records pipeline metadata to an external SIEM, and a manual approval gate requiring sign-off from the compliance-officers group before any production deployment. The development team for patient-records-api writes their own .gitlab-ci.yml with build, test, and deploy jobs, but every pipeline automatically includes the compliance jobs. Even if a developer tries to override a compliance job by defining a job with the same name, the compliance configuration takes precedence. The compliance dashboard at the group level provides a consolidated view of all projects, their framework assignments, and compliance violations, enabling auditors to verify that all patient data services meet HIPAA requirements.
A critical gotcha is the merge behavior between compliance and project configurations. If both define a job with the same name, the compliance version wins entirely, which can silently override a project's intentional job definition. Use unique, prefixed job names in compliance configurations (like compliance-sast, compliance-audit) to avoid collisions. Another issue is performance: compliance jobs add to every pipeline's duration, so keep them focused and fast. Long-running compliance jobs (like full DAST scans) should use rules to run only on merge requests targeting protected branches, not on every commit to every feature branch. Also, remember that compliance frameworks require GitLab Ultimate; on lower tiers, you can approximate the behavior using group-level CI/CD templates with include, but these can be overridden by project maintainers and thus do not provide true compliance enforcement.
Code Example
# Compliance template project: compliance/pipeline-templates
# File: .compliance-ci.yml
# This configuration is automatically injected into all HIPAA-labeled projects
# Define compliance stages that wrap around project stages
stages:
- .pre # GitLab built-in first stage
- build # Project's build stage
- test # Project's test stage
- compliance-scan # Mandatory security scanning
- compliance-audit # Mandatory audit logging
- deploy # Project's deploy stage
- .post # GitLab built-in last stage
# Mandatory SAST scan - cannot be overridden by projects
compliance-sast:
stage: compliance-scan # Runs in compliance scan stage
image: semgrep/semgrep:latest # Use Semgrep for static analysis
script:
- semgrep scan --config=p/owasp-top-ten --json -o gl-sast-report.json . # Scan for OWASP issues
artifacts:
reports:
sast: gl-sast-report.json # Upload SAST report for MR widget
allow_failure: false # Block pipeline on security findings
# Mandatory dependency scanning
compliance-dependency-scan:
stage: compliance-scan # Runs in compliance scan stage
image: registry.gitlab.com/gitlab-org/security-products/analyzers/gemnasium:latest # GitLab analyzer
script:
- /analyzer run # Execute dependency analysis
artifacts:
reports:
dependency_scanning: gl-dependency-scanning-report.json # Upload report
# Mandatory secret detection
compliance-secret-detection:
stage: compliance-scan # Runs in compliance scan stage
image: registry.gitlab.com/gitlab-org/security-products/analyzers/secrets:latest # GitLab analyzer
script:
- /analyzer run # Scan for leaked secrets
artifacts:
reports:
secret_detection: gl-secret-detection-report.json # Upload report
# Mandatory audit logging to external SIEM
compliance-audit-log:
stage: compliance-audit # Runs after security scans
image: curlimages/curl:latest # Lightweight image for API calls
script:
- |
# Send pipeline metadata to Splunk SIEM for audit trail
curl -k -X POST https://splunk.acme.com:8088/services/collector \
-H "Authorization: Splunk $SPLUNK_HEC_TOKEN" \
-d "{
\"event\": {
\"pipeline_id\": \"$CI_PIPELINE_ID\",
\"project\": \"$CI_PROJECT_PATH\",
\"commit\": \"$CI_COMMIT_SHA\",
\"user\": \"$GITLAB_USER_EMAIL\",
\"branch\": \"$CI_COMMIT_BRANCH\",
\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}
}" # Post audit event
# Mandatory approval gate for production deployments
compliance-production-gate:
stage: deploy # Runs before actual deployment
script:
- echo "Production deployment approved by compliance officer" # Log approval
environment:
name: production-gate # Track gate approvals
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Only on default branch
when: manual # Require manual approval
allow_failure: false # Block deployment without approval
# ---------------------------------------------------
# Create compliance framework via API
# ---------------------------------------------------
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"name": "HIPAA",
"description": "Health Insurance Portability and Accountability Act",
"color": "#cc0000",
"pipeline_configuration_full_path": ".compliance-ci.yml@compliance/pipeline-templates"
}' \
"https://gitlab.acme.com/api/v4/groups/5/compliance_frameworks" # Create framework for group 5Interview Tip
A junior engineer typically handles compliance by asking developers to include security templates, which can be removed or skipped. Demonstrate enterprise-grade thinking by explaining that compliance pipelines inject mandatory jobs that cannot be overridden by project maintainers, creating true separation of duties. Describe the architecture: compliance configurations stored in a locked-down separate project, framework labels assigned to regulated projects, and the merge behavior where compliance jobs take precedence. Mention the compliance dashboard for auditor visibility and the distinction from voluntary include-based templates. This shows you understand that security and compliance in CI/CD require enforcement, not just guidance.
💬 Comments
Quick Answer
The Kubernetes executor runs each CI/CD job as a pod in a Kubernetes cluster, providing automatic scaling, resource isolation, and ephemeral build environments. Configuration involves Runner registration with the kubernetes executor, pod spec customization in config.toml, namespace isolation, resource limits, and pod affinity rules for performance optimization.
Detailed Answer
Think of the Kubernetes executor like an on-demand temp agency for a construction company. Instead of maintaining a permanent workforce (static VMs) that sits idle between projects, the company calls the temp agency (Kubernetes) whenever a new project (CI job) comes in. The agency assembles a team (pod) with the exact skills needed (containers with specified images), the team completes the work, and then disbands. The company only pays for hours worked (pod resource usage), and the agency can scale to provide a hundred teams simultaneously during peak construction season.
The Kubernetes executor is one of several executor types supported by GitLab Runner. When configured, each CI/CD job is executed as a Kubernetes pod in the cluster. The pod typically contains at least three containers: a build container running the specified CI job image where the script commands execute, a helper container that handles git cloning, artifact uploading, and cache management, and optionally sidecar containers defined in the services keyword (like PostgreSQL or Redis for testing). The Runner itself runs as a long-lived pod or deployment in the cluster, polling GitLab for jobs. When it picks up a job, it creates a pod specification based on the config.toml settings and the job's requirements, submits it to the Kubernetes API, and monitors its execution. After the job completes, the pod is terminated and its resources are released.
Internally, the Kubernetes executor translates GitLab CI job definitions into Kubernetes pod specifications. The config.toml file allows extensive customization of the generated pods: namespace selection, service account assignment, resource requests and limits (CPU and memory), node affinity and tolerations for targeting specific node pools, volume mounts for persistent storage, pod annotations and labels for monitoring integration, and security context settings like running as non-root. The executor supports three pod cleanup strategies: on success (delete pods after successful jobs), always (delete all pods), and never (keep pods for debugging). For autoscaling, the cluster's node autoscaler works naturally with the Kubernetes executor: when many CI jobs create pods simultaneously, the pending pods trigger the autoscaler to add nodes, and when jobs complete and pods are deleted, the autoscaler removes unnecessary nodes.
In production, a company running 500 CI/CD pipelines per day across 100 microservices would deploy a multi-Runner Kubernetes architecture. The primary Runner deployment runs in a dedicated ci-runners namespace with service accounts scoped to only create pods in that namespace (preventing CI jobs from accessing production workloads). Node pools are configured with specific machine types: a general-purpose pool with e2-standard-4 instances for standard CI jobs, a high-memory pool with e2-highmem-8 instances for compilation-heavy jobs (targeted via node affinity and Runner tags), and a spot/preemptible pool for cost-sensitive jobs like nightly integration tests. Resource limits are set aggressively to prevent runaway jobs from starving the cluster: 2 CPU cores and 4GB RAM as defaults, with specific overrides for known resource-intensive jobs. Pod security policies enforce non-root execution, read-only root filesystems where possible, and disallow privilege escalation. The Runner is deployed via the official GitLab Runner Helm chart, which manages the Runner deployment, configures RBAC, and supports automatic Runner registration.
A critical gotcha is the Docker-in-Docker problem on Kubernetes. Many CI jobs need to build Docker images, which traditionally requires running a Docker daemon inside the CI container (privileged mode). On Kubernetes, this means granting the pod privileged security context, which is a significant security risk. The solution is using Kaniko, Buildah, or buildkit for rootless image builds that do not require a Docker daemon. Another common issue is pod scheduling delays: if the cluster needs to scale up nodes, jobs may wait minutes for a node to become available. Configure the cluster autoscaler's scale-up timeout and over-provision with pause pods (low-priority pods that are preempted when real jobs arrive) to reduce cold-start latency. Also, be aware of the DNS resolution delay in fresh pods; set the dnsPolicy to Default instead of ClusterFirst if CI jobs make heavy external API calls.
Code Example
# GitLab Runner Helm chart values.yaml for Kubernetes executor
# Install with: helm install gitlab-runner gitlab/gitlab-runner -f values.yaml
# GitLab instance connection settings
gitlabUrl: https://gitlab.acme.com/ # GitLab server URL
runnerToken: glrt-RUNNER_REG_TOKEN # Runner registration token
# Runner configuration
concurrent: 50 # Maximum number of concurrent jobs across all Runners
checkInterval: 5 # Poll GitLab every 5 seconds for new jobs
# Kubernetes executor settings
runners:
config: | # Raw config.toml content for Runner configuration
[[runners]] # Runner definition block
name = "k8s-runner-prod" # Runner display name
executor = "kubernetes" # Use Kubernetes executor
[runners.kubernetes] # Kubernetes-specific settings
namespace = "ci-runners" # Namespace for CI job pods
service_account = "gitlab-ci-sa" # Service account for pods
poll_timeout = 600 # Timeout for pod creation (seconds)
# Resource defaults for CI job containers
cpu_request = "500m" # Request 0.5 CPU cores
cpu_limit = "2000m" # Limit to 2 CPU cores
memory_request = "1Gi" # Request 1GB memory
memory_limit = "4Gi" # Limit to 4GB memory
# Helper container resources
helper_cpu_request = "100m" # Helper needs minimal CPU
helper_memory_request = "128Mi" # Helper needs minimal memory
# Pod scheduling configuration
[runners.kubernetes.node_selector] # Target specific nodes
workload-type = "ci" # Only run on CI-labeled nodes
[runners.kubernetes.node_tolerations] # Tolerate CI node taints
"ci-workload" = "true:NoSchedule" # Allow scheduling on tainted nodes
# Pod security context
[runners.kubernetes.pod_security_context] # Security settings
run_as_non_root = true # Enforce non-root execution
run_as_user = 1000 # Run as UID 1000
# Volume mounts for caching
[[runners.kubernetes.volumes.empty_dir]] # Ephemeral volume
name = "docker-cache" # Volume name
mount_path = "/var/lib/docker" # Mount path for Docker cache
medium = "Memory" # Use tmpfs for speed
[[runners.kubernetes.volumes.pvc]] # Persistent volume claim
name = "ci-cache" # PVC name
mount_path = "/cache" # Mount path for CI cache
# RBAC configuration for the Runner
rbac:
create: true # Create RBAC resources
rules: # Custom RBAC rules
- apiGroups: [""] # Core API group
resources: ["pods", "pods/exec", "secrets"] # Required resources
verbs: ["get", "list", "watch", "create", "delete"] # Required verbs
# ---------------------------------------------------
# .gitlab-ci.yml targeting the Kubernetes Runner
# ---------------------------------------------------
build-with-kaniko:
stage: build # Assign to build stage
image:
name: gcr.io/kaniko-project/executor:v1.22.0-debug # Kaniko for rootless builds
entrypoint: [""] # Override entrypoint
script:
- >- # Build without Docker daemon (no privileged mode needed)
/kaniko/executor
--context $CI_PROJECT_DIR
--dockerfile $CI_PROJECT_DIR/Dockerfile
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
--cache=true
--cache-repo=$CI_REGISTRY_IMAGE/cache
tags:
- k8s # Target Kubernetes Runner
# Resource-intensive job targeting high-memory nodes
compile-monolith:
stage: build # Assign to build stage
image: gradle:8.5-jdk21 # Use Gradle with JDK 21
variables:
KUBERNETES_CPU_REQUEST: "2000m" # Override: request 2 cores
KUBERNETES_CPU_LIMIT: "4000m" # Override: limit to 4 cores
KUBERNETES_MEMORY_REQUEST: "4Gi" # Override: request 4GB
KUBERNETES_MEMORY_LIMIT: "8Gi" # Override: limit to 8GB
KUBERNETES_NODE_SELECTOR_OVERWRITE: "pool=high-memory" # Target high-mem nodes
script:
- gradle build --parallel # Compile with parallel execution
tags:
- k8s # Target Kubernetes RunnerInterview Tip
A junior engineer typically knows Runners use Docker and stops there. Demonstrate advanced infrastructure knowledge by explaining the Kubernetes executor's pod architecture (build container, helper container, sidecar services), the config.toml customization for resource limits, node affinity, and security contexts. Discuss the Docker-in-Docker security problem and the Kaniko solution for rootless builds. Mention cluster autoscaling integration, pause pod over-provisioning for reducing cold-start latency, and RBAC scoping for the Runner service account. Cover the Helm chart deployment model and per-job resource overrides using KUBERNETES_* variables. This shows you can operate CI/CD infrastructure at enterprise scale.
💬 Comments
Quick Answer
Auto DevOps is a pre-built CI/CD pipeline that automatically detects your application type (language, framework) and applies a full pipeline with build, test, security scanning, review apps, and deployment stages without requiring a .gitlab-ci.yml file. It uses Herokuish buildpacks or Cloud Native Buildpacks to build images and deploys to Kubernetes using Helm charts.
Detailed Answer
Think of Auto DevOps like a turnkey restaurant franchise. Instead of designing the kitchen layout, creating the menu, hiring staff, and building health inspection processes from scratch, you buy into a franchise (enable Auto DevOps) and get all of that out of the box. The franchise provides standardized recipes (buildpacks), kitchen equipment (build pipeline), quality control processes (testing and security scans), and restaurant management protocols (deployment and monitoring). You can customize the menu (override specific stages), but the core operations are managed by the franchise system.
Auto DevOps is enabled at the project, group, or instance level. When enabled and no .gitlab-ci.yml file exists in the repository, GitLab automatically generates a pipeline based on a set of templates defined in the Auto-DevOps.gitlab-ci.yml template. The pipeline includes five core stages: Auto Build uses buildpacks to create a Docker image from the source code without requiring a Dockerfile (detecting Node.js, Python, Java, Go, Ruby, and other languages automatically). Auto Test runs language-specific tests (npm test, pytest, go test, etc.). Auto Code Quality runs Code Climate analysis. Auto Security includes SAST, DAST, dependency scanning, container scanning, and secret detection. Auto Deploy deploys the built image to a Kubernetes cluster using a Helm chart, with automatic review app creation for merge requests, staging deployment from the default branch, and production deployment with manual gates.
Internally, when Auto DevOps triggers, GitLab fetches the Auto-DevOps.gitlab-ci.yml template and all its included sub-templates from the GitLab source code repository. The build stage uses either Herokuish (legacy) or Cloud Native Buildpacks (current default) to detect the application type from files like package.json, requirements.txt, go.mod, or pom.xml, and then compiles and packages the application into an OCI-compliant Docker image. This image is pushed to the project's container registry. The deploy stage uses a Helm chart (auto-deploy-app) that creates a Kubernetes Deployment, Service, Ingress, and optionally an HPA (Horizontal Pod Autoscaler). Auto DevOps reads environment variables to customize the deployment: KUBE_NAMESPACE sets the target namespace, REPLICAS sets the pod count, HELM_UPGRADE_VALUES_FILE points to custom Helm values. The review app functionality creates per-branch namespaces with unique Ingress hostnames.
In production, Auto DevOps is most valuable for teams that want rapid onboarding with minimal CI/CD expertise. A startup launching a new Python web service can push code to GitLab and have a full pipeline with security scanning and Kubernetes deployment running within minutes without writing a single line of pipeline configuration. However, most mature teams outgrow Auto DevOps as their needs become more specific. The inflection point typically comes when teams need custom test strategies (specific database fixtures, integration test environments), non-standard build processes (multi-stage Docker builds with private base images), deployment targets other than Kubernetes (Lambda, ECS, Cloud Run), or fine-grained control over pipeline stages. The recommended migration path is to run Auto DevOps initially, then export the generated pipeline configuration (visible in the CI/CD > Pipelines > CI Lint as the merged YAML) and convert it into a custom .gitlab-ci.yml, keeping the parts that work and replacing the rest.
A key gotcha is that Auto DevOps makes assumptions that may not match your infrastructure. It assumes you have a Kubernetes cluster connected to GitLab (via the deprecated certificate-based integration or the GitLab Agent for Kubernetes), a wildcard DNS record for review apps, and that your application follows the 12-factor app methodology with a web process listening on port 5000. If any of these assumptions are wrong, Auto DevOps will fail with cryptic Helm or kubectl errors. Another common issue is buildpack detection mismatch: if your repository contains multiple language indicators (package.json and requirements.txt), the buildpack may choose the wrong one. You can force the buildpack using the BUILDPACK_URL variable. Also, Auto DevOps security scanning in the Free tier produces reports but does not display them in the MR widget; the full security dashboard integration requires Ultimate.
Code Example
# Minimal project setup: no .gitlab-ci.yml needed
# Just enable Auto DevOps in Settings > CI/CD > Auto DevOps
# ---------------------------------------------------
# Customizing Auto DevOps with variables
# Set these in Settings > CI/CD > Variables
# ---------------------------------------------------
# Override the default buildpack
# BUILDPACK_URL: https://github.com/heroku/heroku-buildpack-python # Force Python buildpack
# Customize deployment replicas
# PRODUCTION_REPLICAS: 3 # Run 3 pods in production
# STAGING_REPLICAS: 1 # Run 1 pod in staging
# Custom Helm values for deployment
# HELM_UPGRADE_VALUES_FILE: .gitlab/auto-deploy-values.yaml # Path to custom values
# ---------------------------------------------------
# Partial Auto DevOps: use some templates, override others
# .gitlab-ci.yml that extends Auto DevOps selectively
# ---------------------------------------------------
include:
- template: Auto-DevOps.gitlab-ci.yml # Include the full Auto DevOps template
variables:
AUTO_DEVOPS_BUILD_IMAGE_CNB_ENABLED: "true" # Use Cloud Native Buildpacks
AUTO_DEVOPS_DEPLOY_DEBUG: "true" # Enable deploy debugging
POSTGRES_ENABLED: "false" # Disable auto-provisioned PostgreSQL
TEST_DISABLED: "true" # Disable auto-test if using custom tests
DAST_DISABLED: "true" # Disable DAST if not needed
# Override the auto test job with custom tests
test:
image: python:3.12-slim # Use Python image instead of auto-detected
script:
- pip install -r requirements.txt # Install dependencies
- pytest tests/ -v --junitxml=report.xml # Run custom test suite
artifacts:
reports:
junit: report.xml # Upload test results to MR widget
# Add a custom job not in Auto DevOps
performance-test:
stage: test # Add to test stage
image: grafana/k6:latest # Use k6 for load testing
script:
- k6 run loadtests/smoke.js # Run performance tests
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Only on default branch
# ---------------------------------------------------
# Custom auto-deploy Helm values
# File: .gitlab/auto-deploy-values.yaml
# ---------------------------------------------------
# replicaCount: 3 # Number of pod replicas
# image:
# pullPolicy: Always # Always pull latest image
# resources:
# requests:
# cpu: 250m # CPU request per pod
# memory: 256Mi # Memory request per pod
# limits:
# cpu: 500m # CPU limit per pod
# memory: 512Mi # Memory limit per pod
# ingress:
# annotations:
# nginx.ingress.kubernetes.io/proxy-body-size: "50m" # Allow large uploads
# ---------------------------------------------------
# View the generated Auto DevOps pipeline config
# ---------------------------------------------------
# Use CI Lint to see the merged YAML:
# Navigate to: CI/CD > Pipelines > CI Lint
# Or use the API:
curl --header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
"https://gitlab.acme.com/api/v4/projects/15/ci/lint?dry_run=true" # View merged pipeline YAMLInterview Tip
A junior engineer typically either has never used Auto DevOps or enabled it without understanding the internals. Demonstrate depth by explaining the five core stages (build, test, code quality, security, deploy), the buildpack detection mechanism, and the Helm-based Kubernetes deployment model. Discuss when to use Auto DevOps (rapid prototyping, teams without CI/CD expertise, standardized applications) versus when to migrate to custom pipelines (complex build processes, non-Kubernetes targets, custom test strategies). Mention the migration path of exporting the generated configuration via CI Lint. Cover the key assumptions Auto DevOps makes (Kubernetes cluster, port 5000, wildcard DNS) and what breaks when they are wrong.
💬 Comments
Quick Answer
The GitLab Kubernetes Agent (KAS) establishes a secure, pull-based connection from a Kubernetes cluster to GitLab using an agent pod that maintains an outbound WebSocket tunnel. Unlike the deprecated certificate-based integration (which required exposing the cluster API), KAS does not require inbound network access, supports GitOps workflows with manifest synchronization, and provides granular RBAC-based access control.
Detailed Answer
Think of the GitLab Kubernetes Agent like a diplomatic embassy. In the old model (certificate-based integration), your country (GitLab) needed direct access to the foreign country's government building (Kubernetes API), requiring special passports (certificates) and open borders (firewall rules). With KAS, the foreign country (Kubernetes cluster) sends an ambassador (agent pod) to your country, who maintains a secure communication channel (WebSocket tunnel) back home. The ambassador relays messages between the two governments without either side needing to open their borders. The ambassador lives within the foreign country's jurisdiction and follows its laws (RBAC), making the arrangement both secure and sovereign.
The GitLab Kubernetes Agent consists of two components: agentk (the agent that runs as a pod inside the Kubernetes cluster) and KAS (Kubernetes Agent Server, a GitLab-side component that manages agent connections). The agent is registered in a GitLab project by creating a configuration file at .gitlab/agents/<agent-name>/config.yaml. After registration, GitLab provides a token that the agent uses to authenticate. The agent pod establishes an outbound gRPC/WebSocket connection to KAS, creating a persistent bidirectional tunnel. This tunnel is used for two purposes: CI/CD access (allowing CI/CD jobs to run kubectl commands against the cluster through the tunnel without exposing the Kubernetes API) and GitOps synchronization (the agent watches a GitLab repository for Kubernetes manifests and automatically applies changes when files are updated, similar to Flux or ArgoCD).
Internally, when a CI/CD job needs to interact with a Kubernetes cluster via the agent, the job uses the kubeconfig provided by the agent integration. The job's kubectl commands are sent through the Runner to KAS, which routes them through the WebSocket tunnel to the agentk pod in the target cluster. The agentk pod executes the kubectl commands using its own service account's RBAC permissions. For GitOps mode, the agentk pod runs a reconciliation loop: it periodically pulls the configured GitLab repository, compares the manifest files against the cluster's current state, and applies any differences. This pull-based model means the cluster never needs to accept inbound connections, and changes are applied through the agent's service account with its RBAC constraints. The agent also supports inventory management, tracking which resources it has created and cleaning up resources that are removed from the manifests.
In production, a company with clusters in multiple environments would deploy agents strategically. The staging cluster has an agent named staging-agent with CI/CD access enabled, allowing deploy jobs to run kubectl and helm commands against staging. The production cluster has a production-agent with GitOps mode enabled: the agent watches the k8s/production/ directory in the deployment-manifests repository and automatically applies changes when merge requests are merged to main. This GitOps workflow means no one (not even CI/CD pipelines) runs kubectl commands against production directly. All changes go through merge requests in the manifests repository, providing a complete audit trail. Access is controlled through the agent's configuration file, which specifies which projects and groups are authorized to use the agent. A team managing order-processing-service would configure their agent to allow CI/CD access from the services/order-processing group and GitOps sync from the deployments/production project.
A critical gotcha is the RBAC scoping of the agent's service account. If the agent runs with cluster-admin privileges, any CI/CD job that uses the agent effectively has unrestricted access to the entire cluster. Always create a dedicated service account for the agent with the minimum required permissions: for CI/CD access, limit it to specific namespaces; for GitOps, limit it to the resources it needs to manage. Another common issue is the agent configuration file location: it must be at exactly .gitlab/agents/<agent-name>/config.yaml in the project where the agent is registered. If the file is missing or in the wrong location, the agent will connect but have no configuration. Also, be aware that GitOps mode and CI/CD access are independent features that can be enabled separately; many teams use CI/CD access for staging (where they want flexibility) and GitOps for production (where they want strict auditability).
Code Example
# Agent configuration file
# Path: .gitlab/agents/production-agent/config.yaml
# This file configures the GitLab Kubernetes Agent
# CI/CD access configuration
ci_access: # Enable CI/CD tunnel access
projects: # List of projects authorized to use this agent
- id: services/order-processing-service # Project path
- id: services/payment-service # Another authorized project
groups: # List of groups authorized to use this agent
- id: infrastructure # All projects in infrastructure group
# GitOps configuration
gitops: # Enable GitOps manifest synchronization
manifest_projects: # Repositories containing Kubernetes manifests
- id: deployments/production-manifests # Project containing manifests
default_namespace: production # Default namespace for resources
paths: # Paths within the repository to watch
- glob: 'k8s/production/**/*.yaml' # Watch all YAML files in this path
reconcile_timeout: 3600s # Timeout for apply operations
dry_run_strategy: none # Apply changes for real (not dry run)
# Observability configuration
observability: # Agent observability settings
logging:
level: info # Set agent log level
# ---------------------------------------------------
# Install the agent in the Kubernetes cluster
# ---------------------------------------------------
# Add the GitLab Helm repository
helm repo add gitlab https://charts.gitlab.io # Add GitLab chart repository
helm repo update # Update chart index
# Install the agent using Helm
helm upgrade --install production-agent gitlab/gitlab-agent \
--namespace gitlab-agent \
--create-namespace \
--set config.token=glagent-AGENT_TOKEN \
--set config.kasAddress=wss://kas.gitlab.acme.com # Install agent with token
# ---------------------------------------------------
# .gitlab-ci.yml using the Kubernetes Agent for deployment
# ---------------------------------------------------
stages:
- build # Build stage
- deploy # Deploy stage
build-service:
stage: build # Assign to build stage
image: docker:24.0 # Use Docker image
services:
- docker:24.0-dind # Docker-in-Docker service
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . # Build the image
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Push to registry
# Deploy using the Kubernetes Agent CI/CD tunnel
deploy-staging:
stage: deploy # Assign to deploy stage
image: bitnami/kubectl:1.29 # Use kubectl image
script:
- kubectl config use-context services/order-processing-service:staging-agent # Select agent context
- kubectl get nodes # Verify cluster connectivity
- helm upgrade --install order-service ./helm/ # Deploy with Helm
--namespace staging # Target namespace
--set image.tag=$CI_COMMIT_SHA # Use built image tag
environment:
name: staging # Track staging environment
kubernetes:
namespace: staging # Associate with Kubernetes namespace
# GitOps deployment: just update manifests, agent handles the rest
deploy-production-gitops:
stage: deploy # Assign to deploy stage
image: alpine:3.19 # Lightweight image
script:
- apk add --no-cache git # Install git
- git clone https://gitlab-ci-token:[email protected]/deployments/production-manifests.git # Clone manifests repo
- cd production-manifests # Enter manifests directory
- sed -i "s|image:.*|image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA|" k8s/production/deployment.yaml # Update image tag
- git add . && git commit -m "Deploy order-service $CI_COMMIT_SHA" # Commit change
- git push origin main # Push to trigger GitOps sync
environment:
name: production # Track production deployment
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only from main branch
when: manual # Manual approval requiredInterview Tip
A junior engineer typically describes Kubernetes deployment as 'we run kubectl in CI' without understanding the connectivity model. Demonstrate advanced knowledge by explaining the pull-based architecture of KAS: the agent pod initiates an outbound WebSocket connection to GitLab, eliminating the need to expose the cluster API. Contrast this with the deprecated certificate-based integration that required inbound access and cluster credentials stored in GitLab. Describe both CI/CD access mode (tunnel for kubectl commands) and GitOps mode (manifest synchronization). Discuss the RBAC scoping of the agent's service account and why production should use GitOps mode for auditability while staging uses CI/CD access for flexibility.
💬 Comments
Quick Answer
GitLab provides a built-in HTTP backend for Terraform state files, accessible per-project via the GitLab API. State is versioned, encrypted at rest, locked during operations, and integrated with merge request workflows that show terraform plan output as a widget. It eliminates the need for separate S3 buckets, DynamoDB tables, or Terraform Cloud for state management.
Detailed Answer
Think of GitLab's Terraform state backend like a safety deposit box at a bank where you already have your checking account. Instead of renting a separate vault (S3 bucket) at another institution (AWS) and managing the keys (IAM policies) and access logs (DynamoDB for locking) independently, you keep everything in one bank (GitLab). The bank provides the vault (state storage), the lock (state locking), the access log (version history), and the security cameras (audit events), all under the same account (project) where you do your other business (code, CI/CD, merge requests).
GitLab's Terraform state backend is an HTTP backend that stores state files within GitLab projects. Each project can host multiple state files, identified by name, enabling teams to manage multiple infrastructure environments (staging, production) or components (networking, compute, databases) from a single repository. The backend supports state locking, which prevents concurrent terraform apply operations from corrupting the state. State files are versioned: every time the state changes, a new version is stored, and previous versions can be downloaded or restored. The state is encrypted at rest using GitLab's encryption key. Authentication uses personal access tokens, CI job tokens, or deploy tokens, making it seamless to use from GitLab CI/CD pipelines.
Internally, when Terraform initializes with the GitLab HTTP backend, it sends HTTP requests to the GitLab API at /api/v4/projects/:id/terraform/state/:name. A GET request retrieves the current state, a POST (or PUT) request updates the state, and DELETE removes it. State locking uses the LOCK and UNLOCK HTTP methods defined in the Terraform HTTP backend protocol. When terraform plan or terraform apply begins, Terraform sends a LOCK request with a unique lock ID. If the state is already locked by another operation, the request is rejected with a 409 Conflict status, preventing concurrent modifications. When the operation completes, Terraform sends an UNLOCK request. GitLab stores state versions as blobs in the database or object storage, linked to the project. The Terraform state list is visible in the project sidebar under Infrastructure > Terraform, showing each state's name, last modified time, number of resources, and last pipeline that modified it.
In production, an infrastructure team managing cloud resources for logistics-platform would structure their Terraform workflow around GitLab's state backend. The repository contains separate directories for each infrastructure layer: networking/ (VPCs, subnets, firewalls), compute/ (GKE clusters, node pools), databases/ (Cloud SQL instances), and monitoring/ (Datadog dashboards). Each layer has its own state file (networking-prod, compute-prod, databases-prod), preventing a change to monitoring from accidentally affecting the database configuration. The CI/CD pipeline runs terraform plan on merge requests and posts the plan output as a note on the MR, allowing reviewers to see exactly what infrastructure changes will be made. The terraform apply runs after the MR is merged, using a manual approval gate. State locking ensures that even if two MRs are merged in quick succession, the applies run sequentially rather than corrupting the state. The team configures CI/CD variables for cloud provider credentials (GCP_SERVICE_ACCOUNT_KEY, AWS_ACCESS_KEY_ID) as protected and masked variables, ensuring they are only available on the protected main branch.
A critical gotcha is the state lock timeout. If a pipeline job running terraform apply is cancelled or times out without releasing the lock, the state remains locked and subsequent operations will fail with a lock error. GitLab provides a force-unlock mechanism through the API (DELETE /api/v4/projects/:id/terraform/state/:name/lock) and the UI (Infrastructure > Terraform > Actions > Unlock), but you should also configure Terraform with a lock timeout to handle transient issues. Another common mistake is using the CI_JOB_TOKEN for state access without understanding its scope: the job token only has access to the project where the pipeline runs, so cross-project state access requires a personal access token or deploy token. Also, be aware that deleting a state from GitLab does not destroy the underlying infrastructure; it only removes Terraform's tracking of those resources, potentially creating orphaned cloud resources.
Code Example
# Terraform backend configuration for GitLab
# File: backend.tf
terraform {
backend "http" { # Use GitLab's HTTP backend
# Backend configuration is provided via CI/CD variables
# Do not hardcode URLs or tokens in the configuration file
}
}
# ---------------------------------------------------
# .gitlab-ci.yml for Terraform workflow
# ---------------------------------------------------
include:
- template: Terraform.gitlab-ci.yml # Include GitLab's Terraform template
variables:
TF_ROOT: infrastructure/networking # Path to Terraform configuration
TF_STATE_NAME: networking-prod # Name for the state file in GitLab
TF_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}" # State backend URL
stages:
- validate # Validate Terraform configuration
- plan # Generate execution plan
- apply # Apply infrastructure changes
# Initialize and validate Terraform
tf-validate:
stage: validate # Assign to validate stage
image: hashicorp/terraform:1.7 # Use official Terraform image
script:
- cd $TF_ROOT # Navigate to Terraform directory
- terraform init # Initialize backend and providers
-backend-config="address=${TF_ADDRESS}"
-backend-config="lock_address=${TF_ADDRESS}/lock"
-backend-config="unlock_address=${TF_ADDRESS}/lock"
-backend-config="username=gitlab-ci-token"
-backend-config="password=${CI_JOB_TOKEN}"
-backend-config="lock_method=POST"
-backend-config="unlock_method=DELETE"
-backend-config="retry_wait_min=5" # Initialize with GitLab backend
- terraform validate # Validate configuration syntax
- terraform fmt -check # Check formatting
# Generate and display the execution plan
tf-plan:
stage: plan # Assign to plan stage
image: hashicorp/terraform:1.7 # Use official Terraform image
script:
- cd $TF_ROOT # Navigate to Terraform directory
- terraform init -backend-config="address=${TF_ADDRESS}" -backend-config="lock_address=${TF_ADDRESS}/lock" -backend-config="unlock_address=${TF_ADDRESS}/lock" -backend-config="username=gitlab-ci-token" -backend-config="password=${CI_JOB_TOKEN}" -backend-config="lock_method=POST" -backend-config="unlock_method=DELETE" # Initialize backend
- terraform plan -out=plan.cache # Generate plan and save to file
- terraform show -json plan.cache > plan.json # Convert plan to JSON for MR widget
artifacts:
paths:
- $TF_ROOT/plan.cache # Save plan for apply stage
reports:
terraform: $TF_ROOT/plan.json # Upload plan for MR widget display
# Apply the plan (manual trigger, protected branch only)
tf-apply:
stage: apply # Assign to apply stage
image: hashicorp/terraform:1.7 # Use official Terraform image
script:
- cd $TF_ROOT # Navigate to Terraform directory
- terraform init -backend-config="address=${TF_ADDRESS}" -backend-config="lock_address=${TF_ADDRESS}/lock" -backend-config="unlock_address=${TF_ADDRESS}/lock" -backend-config="username=gitlab-ci-token" -backend-config="password=${CI_JOB_TOKEN}" -backend-config="lock_method=POST" -backend-config="unlock_method=DELETE" # Initialize backend
- terraform apply plan.cache # Apply the saved plan
dependencies:
- tf-plan # Receive plan artifact from plan stage
environment:
name: production # Track as production deployment
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only on main branch
when: manual # Require manual approval
# Force-unlock state if a job was cancelled mid-apply
# curl --request DELETE \
# --header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
# "https://gitlab.acme.com/api/v4/projects/15/terraform/state/networking-prod/lock"Interview Tip
A junior engineer typically uses S3 + DynamoDB for Terraform state without considering GitLab's built-in alternative. Demonstrate platform consolidation thinking by explaining GitLab's HTTP backend: state locking via HTTP LOCK/UNLOCK, versioning for rollback, encryption at rest, and the terraform plan MR widget integration. Compare the operational overhead: S3 + DynamoDB requires managing IAM policies, bucket policies, and DynamoDB tables across accounts, while GitLab's backend requires zero additional infrastructure. Discuss the CI_JOB_TOKEN authentication for seamless CI/CD integration and the gotcha of lock timeouts when jobs are cancelled. Mention the multi-state pattern for separating infrastructure layers.
💬 Comments
Quick Answer
GitLab audit events record security-relevant actions (user logins, permission changes, merge request approvals, project settings modifications) with timestamps and actor information. Audit event streaming forwards these events in real-time to external destinations (SIEM, HTTP endpoints, AWS S3, Google Cloud Logging) via webhooks, enabling centralized security monitoring and compliance reporting.
Detailed Answer
Think of audit events like the security camera system in a corporate office. Every door opening (authentication event), every file access (repository action), every safe combination change (permission modification) is recorded on video (audit log). Audit event streaming is like connecting that camera system to a central monitoring station (SIEM) across town, where security guards (security team) watch all feeds from all buildings (GitLab instances) in real time and sound alarms (alerts) when they spot suspicious activity.
GitLab audit events capture a wide range of actions across the platform. At the user level: login attempts (successful and failed), two-factor authentication changes, personal access token creation and revocation, and SSH key additions. At the group level: member additions and removals, role changes, group setting modifications, and SAML/SCIM provisioning events. At the project level: merge request approvals and merges, protected branch changes, CI/CD variable modifications, deploy token creation, repository visibility changes, and Runner registration. Each event records the timestamp, the actor (who performed the action), the target (what was affected), the action type, and additional metadata like IP address and user agent. Audit events are available in the GitLab UI at the instance, group, and project levels, and via the GraphQL API for programmatic access.
Internally, GitLab generates audit events through an event tracking system embedded in the Rails application. When a controller or service object performs an auditable action, it calls the audit event service, which creates an AuditEvent record in the database with the relevant metadata. For streaming, GitLab maintains a list of streaming destinations configured at the group or instance level. When an audit event is created, GitLab serializes it to JSON and sends an HTTP POST request to each configured streaming destination. The payload includes the event type, the full event details, and a verification header (X-Gitlab-Event-Streaming-Token) that the destination can use to validate the request authenticity. Streaming is asynchronous to avoid slowing down user-facing operations: events are queued in Sidekiq (GitLab's background job processor) and delivered with automatic retries on failure. GitLab supports streaming to generic HTTPS endpoints, AWS S3 buckets, and Google Cloud Logging as native destinations.
In production, a financial services company subject to SOX and PCI-DSS compliance would configure audit event streaming to their Splunk SIEM. The security team creates streaming destinations at the top-level group, ensuring all subgroups and projects inherit the configuration. Events flow to Splunk in real-time, where the security team builds dashboards and alerts: a dashboard showing all permission escalations in the last 24 hours, an alert for any CI/CD variable marked as protected being modified, an alert for any user receiving Owner role on a production project, and a report of all merge request approvals on projects tagged with the PCI-DSS compliance framework. For incident response, the security team uses the audit event GraphQL API to query specific timeframes and actors, answering questions like 'what did user john.doe do between 2AM and 4AM on March 15th?' The audit event data feeds into their quarterly compliance reports, demonstrating to auditors that all code changes to payment-processing systems were reviewed, approved, and deployed through the approved pipeline.
A critical gotcha is that audit event retention in GitLab's database is configurable by instance administrators, and events may be purged after a retention period (or may not, depending on the configuration). For compliance, always stream events to an external system with its own retention policy that meets regulatory requirements (typically 7 years for SOX, 1 year for PCI-DSS). Another common issue is the volume of events: a large GitLab instance can generate thousands of events per hour, and streaming destinations must be able to handle the throughput. Configure rate limiting and batching on the receiving end. Also, not all actions generate audit events; GitLab continuously expands the list of audited actions, but some operations (like viewing a file or reading a CI variable value) are not currently audited. Check the documentation for the complete list of audited events.
Code Example
# Configure audit event streaming destination via API
# Create a streaming destination for a group
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"destination_url": "https://splunk-hec.acme.com:8088/services/collector",
"verification_token": "secret-verification-token-123"
}' \
"https://gitlab.acme.com/api/v4/groups/5/audit_events/streaming/destinations" # Create HTTPS streaming destination
# Create an AWS S3 streaming destination
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"name": "compliance-audit-logs",
"bucket_name": "acme-gitlab-audit-logs",
"access_key_xid": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"aws_region": "us-east-1"
}' \
"https://gitlab.acme.com/api/v4/groups/5/audit_events/streaming/destinations/amazon_s3" # Create S3 destination
# Add custom headers for SIEM authentication
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"key": "Authorization",
"value": "Splunk abc123-hec-token"
}' \
"https://gitlab.acme.com/api/v4/groups/5/audit_events/streaming/destinations/42/headers" # Add auth header
# Query audit events using GraphQL API
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"query": "{ group(fullPath: \"acme-corp\") { auditEvents(first: 50, after: null) { nodes { id createdAt author { name username } entityType entityPath details } pageInfo { endCursor hasNextPage } } } }"
}' \
"https://gitlab.acme.com/api/graphql" # Query audit events via GraphQL
# Query project-level audit events via REST API
curl --header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
"https://gitlab.acme.com/api/v4/projects/15/audit_events?created_after=2026-06-01&created_before=2026-06-30" # Get June audit events
# ---------------------------------------------------
# Example audit event payload received at streaming destination
# ---------------------------------------------------
# {
# "id": 1234567,
# "author_id": 42,
# "entity_id": 15,
# "entity_type": "Project",
# "details": {
# "change": "visibility_level",
# "from": "private",
# "to": "internal",
# "author_name": "Jane Smith",
# "author_email": "[email protected]",
# "target_id": 15,
# "target_type": "Project",
# "target_details": "payment-service",
# "ip_address": "10.0.1.42",
# "entity_path": "services/payment-service",
# "custom_message": "Changed project visibility from private to internal"
# },
# "created_at": "2026-06-25T14:30:00.000Z"
# }
# ---------------------------------------------------
# .gitlab-ci.yml job to generate compliance report from audit events
# ---------------------------------------------------
compliance-report:
stage: report # Assign to report stage
image: python:3.12-slim # Use Python for report generation
script:
- pip install requests jinja2 # Install dependencies
- python scripts/generate_compliance_report.py # Generate report from audit API
--group acme-corp # Target group
--start-date 2026-06-01 # Report start date
--end-date 2026-06-30 # Report end date
--output compliance-report.html # Output file
artifacts:
paths:
- compliance-report.html # Save generated report
rules:
- if: $CI_PIPELINE_SOURCE == "schedule" # Run on scheduled pipeline onlyInterview Tip
A junior engineer typically does not know about audit events or confuses them with CI/CD pipeline logs. Demonstrate enterprise security knowledge by explaining the scope of audit events (user actions, permission changes, project settings, CI/CD modifications), the streaming architecture (asynchronous HTTP delivery with verification tokens), and the integration with SIEMs like Splunk, Datadog, or Elastic. Discuss compliance use cases: SOX requires evidence that all production changes were approved, PCI-DSS requires access logs for cardholder data systems. Mention the GraphQL API for programmatic querying, the retention considerations for long-term compliance, and the gotcha of not all actions being audited. This shows you understand that security is about detection and evidence, not just prevention.
💬 Comments
Quick Answer
Pipeline optimization involves reducing unnecessary work (rules with changes, needs for DAG parallelism, interruptible for auto-cancellation), speeding up individual jobs (caching, smaller images, artifact scoping), and architectural improvements (parent-child pipelines for monorepos, merge trains for throughput). Diagnosis uses pipeline analytics, job duration analysis, and Runner queue metrics.
Detailed Answer
Think of pipeline optimization like optimizing a restaurant's dinner service. You identify bottlenecks: is the kitchen slow because one chef does everything sequentially (no parallelism)? Are servers running back to the pantry for every order (no caching)? Are you cooking dishes no one ordered (running unnecessary jobs)? Are you washing every pot between courses even when it is still clean (rebuilding unchanged components)? Systematic optimization addresses each bottleneck with targeted changes rather than throwing more cooks (Runners) at the problem.
Pipeline optimization starts with reducing unnecessary work. The rules keyword with changes triggers jobs only when specific files are modified, so a backend test job does not run when only documentation changes. The interruptible keyword marks jobs that should be automatically cancelled when a newer pipeline starts for the same branch, preventing wasted Runner capacity on outdated commits. The workflow keyword at the pipeline level can skip pipeline creation entirely for certain conditions. The needs keyword enables DAG parallelism, allowing jobs to start as soon as their specific dependencies complete rather than waiting for entire stages. For merge request pipelines, using only: merge_requests ensures the full pipeline only runs when an MR exists, while pushes to branches get a lighter validation pipeline.
Internally, GitLab provides several tools for diagnosing pipeline performance. The pipeline analytics page (CI/CD > Analytics) shows pipeline duration trends, success rates, and the most frequently failing jobs. The job view shows individual job duration and queuing time (the gap between when a job is created and when a Runner picks it up). High queuing times indicate Runner capacity problems. Long job durations need per-job investigation: is the job spending time downloading dependencies (caching problem), pulling large Docker images (image optimization problem), running sequential commands that could be parallelized, or waiting for services to start (Docker service readiness problem)? The pipeline mini-graph on merge requests shows the critical path through the pipeline: the longest chain of sequential jobs that determines the overall duration. Optimizing jobs not on the critical path does not reduce the pipeline duration.
In production, a team optimizing the pipeline for e-commerce-platform (which had grown to 35 minutes) would apply a systematic approach. First, they identify the critical path: build (5min) -> integration-tests (12min) -> staging-deploy (3min) = 20 minutes minimum. Then they target each segment. The build job is slow because it downloads npm packages every time; adding cache with key:files pointing to package-lock.json cuts it to 2 minutes. The integration test job runs 800 tests sequentially; splitting it into four parallel jobs with parallel: 4 reduces it from 12 to 4 minutes. The Docker image pull takes 90 seconds per job; switching from the 1.2GB node:20 image to the 180MB node:20-alpine image cuts it to 15 seconds. Non-critical jobs like linting and SAST are moved off the critical path using needs: [] to run in parallel from pipeline start. The changes keyword ensures frontend tests only run when frontend files change, and backend tests only run when backend files change. The final optimized pipeline runs in 9 minutes, a 74% improvement.
A critical gotcha is over-optimizing with caching. Aggressive caching can mask dependency issues: if a cached node_modules directory contains a package version that has been removed from the registry, the pipeline passes locally but fails when the cache expires or on a fresh Runner. Always run npm ci (which deletes node_modules before installing) rather than npm install, and ensure your pipeline works correctly even when the cache misses. Another common mistake is using parallel without considering artifact size; splitting a test job into 10 parallel instances means 10 artifact uploads and downloads, which can overwhelm the Runner's network connection. Also, be cautious with interruptible on deploy jobs: if a deployment job is cancelled mid-way, it can leave the environment in an inconsistent state.
Code Example
# Optimized .gitlab-ci.yml for e-commerce-platform
# Define stages with clear purpose
stages:
- prepare # Dependency installation
- validate # Fast checks (lint, type-check)
- build # Compile and package
- test # Automated tests
- security # Security scans
- deploy # Deployment
# Use workflow to skip pipelines for certain conditions
workflow:
rules:
- if: $CI_COMMIT_MESSAGE =~ /\[skip ci\]/ # Skip pipeline for docs-only commits
when: never
- if: $CI_MERGE_REQUEST_IID # Run for merge requests
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run for default branch
- if: $CI_COMMIT_TAG # Run for tags
# Default settings applied to all jobs
default:
image: node:20-alpine # Use lightweight Alpine image (180MB vs 1.2GB)
interruptible: true # Cancel outdated pipelines automatically
retry:
max: 1 # Retry once on infrastructure failures
when:
- runner_system_failure # Only retry on Runner issues
# Install and cache dependencies once
prepare-deps:
stage: prepare # Assign to prepare stage
script:
- npm ci --prefer-offline # Install from lockfile, prefer cached packages
cache:
key:
files:
- package-lock.json # Cache key based on lockfile hash
paths:
- node_modules/ # Cache installed packages
- .npm/ # Cache npm download cache
policy: push # Only upload cache (other jobs will pull)
artifacts:
paths:
- node_modules/ # Pass node_modules to downstream jobs
expire_in: 30 minutes # Short-lived artifact
# Fast validation jobs run in parallel, no stage dependencies
lint:
stage: validate # Assign to validate stage
needs:
- prepare-deps # Only needs dependency installation
script:
- npm run lint # Run ESLint
rules:
- changes: # Only run when source files change
- "src/**/*.{ts,tsx,js,jsx}" # Watch source files
- ".eslintrc*" # Watch ESLint config
type-check:
stage: validate # Runs in parallel with lint
needs:
- prepare-deps # Only needs dependency installation
script:
- npm run type-check # Run TypeScript compiler
rules:
- changes: # Only run when TypeScript files change
- "src/**/*.{ts,tsx}" # Watch TypeScript files
- "tsconfig.json" # Watch TS config
# Build stage
build-app:
stage: build # Assign to build stage
needs:
- prepare-deps # Depends on deps, NOT on lint/type-check (DAG optimization)
script:
- npm run build # Build the application
artifacts:
paths:
- dist/ # Save build output
expire_in: 1 hour # Short retention
# Parallel test execution using GitLab's parallel keyword
unit-tests:
stage: test # Assign to test stage
needs:
- prepare-deps # Only needs dependencies
parallel: 4 # Split into 4 parallel jobs
script:
- npm run test:unit -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL # Shard tests across parallel jobs
artifacts:
reports:
junit: test-results/junit.xml # Upload test results
rules:
- changes: # Only run when source or test files change
- "src/**/*" # Watch source files
- "tests/**/*" # Watch test files
# Security scan runs from pipeline start (no dependencies)
sast-scan:
stage: security # Assign to security stage
needs: [] # No dependencies, starts immediately
image: semgrep/semgrep:latest # Use Semgrep
script:
- semgrep scan --config auto --json -o gl-sast-report.json . # Run SAST
artifacts:
reports:
sast: gl-sast-report.json # Upload SAST report
interruptible: true # Can be cancelled safely
# Deploy only runs on main, after critical jobs
deploy-staging:
stage: deploy # Assign to deploy stage
needs:
- build-app # Needs build artifacts
- unit-tests # Needs tests to pass
interruptible: false # NEVER cancel a deploy mid-way
script:
- kubectl apply -f k8s/staging/ # Deploy to staging
environment:
name: staging # Track staging
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only on mainInterview Tip
A junior engineer typically answers 'add more Runners' when asked about slow pipelines. Demonstrate systematic optimization skills by explaining the three pillars: reduce unnecessary work (rules with changes, interruptible, workflow rules), speed up individual jobs (caching with key:files, Alpine images, parallel keyword for test sharding), and optimize the pipeline architecture (DAG with needs for critical path optimization). Discuss diagnostic tools: pipeline analytics for trends, job duration breakdown for identifying bottlenecks, and queuing time for Runner capacity issues. Mention the concept of the critical path and explain that optimizing jobs not on the critical path does not reduce overall duration. This shows you approach performance problems analytically.
💬 Comments
Quick Answer
GitLab HA architecture distributes components across multiple nodes: load-balanced Rails application nodes, Gitaly cluster for Git repository storage with replication, PostgreSQL with Patroni for database HA, Redis Sentinel for cache and queue HA, and Sidekiq workers for background processing. Object storage (S3/GCS) handles artifacts, uploads, and LFS objects, eliminating single points of failure.
Detailed Answer
Think of GitLab HA architecture like a hospital's critical infrastructure. The hospital cannot have a single entrance (load balancer), a single operating room (application server), a single pharmacy (database), or a single medical records room (repository storage). Instead, there are multiple entrances with triage (load balancers), multiple operating theaters (Rails nodes), a pharmacy with redundant inventory systems (PostgreSQL with Patroni), medical records stored in multiple fireproof vaults (Gitaly cluster), and a dispatch center (Redis Sentinel) that coordinates staff assignments even if one pager system fails. If any single component fails, the hospital continues operating while the failed component is repaired.
GitLab's reference architecture for HA deployments consists of several tiers. The external load balancer (HAProxy, NGINX, or cloud LB) distributes HTTP/HTTPS traffic across multiple GitLab Rails application nodes, which handle the web interface, API, and Git over HTTPS. An internal load balancer routes traffic between internal services. GitLab Rails nodes are stateless and horizontally scalable; you can add more nodes to handle increased traffic. Gitaly is the service responsible for all Git operations (clone, push, pull, diff). In HA mode, Gitaly Cluster uses Praefect as a proxy that replicates repository data across multiple Gitaly storage nodes. PostgreSQL stores all application data (users, projects, issues, merge requests, CI/CD configuration) and runs with Patroni for automatic leader election and failover, backed by PgBouncer for connection pooling. Redis handles caching, session storage, and Sidekiq job queues, running with Sentinel for automatic failover. Sidekiq workers process background jobs (sending emails, updating caches, processing webhooks, CI/CD pipeline scheduling). Object storage (S3, GCS, MinIO) stores large binary data: CI artifacts, LFS objects, package registry files, and container registry blobs.
Internally, Gitaly Cluster is the most complex HA component. Praefect sits between the Rails application and the Gitaly nodes, routing Git operations to the primary node and asynchronously replicating writes to secondary nodes. When the primary Gitaly node fails, Praefect promotes a secondary to primary, assuming it has the latest data. The replication model is eventually consistent: writes are acknowledged after the primary processes them, and replication to secondaries happens asynchronously (or synchronously with strong consistency mode, which waits for replication before acknowledging). PostgreSQL HA uses Patroni, which monitors the PostgreSQL primary and automates failover by promoting a replica if the primary becomes unresponsive. Consul provides distributed locking and service discovery, allowing Patroni nodes to coordinate leader election and allowing Rails nodes to discover the current database primary. PgBouncer sits in front of PostgreSQL to pool connections, preventing the database from being overwhelmed by hundreds of Rails and Sidekiq connections.
In production, a company serving 5,000 developers with GitLab would deploy the 5K reference architecture: 3 external load balancers (for LB redundancy), 5 GitLab Rails application nodes (handling web UI and API traffic), 3 Gitaly Cluster nodes with Praefect (storing all Git repositories with replication), 3 PostgreSQL nodes with Patroni (primary + 2 replicas), 3 Redis nodes with Sentinel (primary + 2 replicas), 4 Sidekiq nodes (processing background jobs with dedicated queues), and object storage via AWS S3 or GCS. The infrastructure is provisioned using Terraform and configured with Ansible or the GitLab Environment Toolkit (GET). Monitoring uses Prometheus (GitLab ships with built-in metrics exporters) and Grafana dashboards that track Rails request latency, Gitaly RPC duration, PostgreSQL replication lag, Sidekiq queue depth, and Redis memory usage. Alerting is configured for critical indicators: PostgreSQL replication lag exceeding 30 seconds, Sidekiq queue depth exceeding 10,000 jobs, Gitaly node becoming unreachable, and Rails 5xx error rate exceeding 1%.
A critical gotcha in HA deployments is the NFS dependency. Older GitLab versions used NFS for shared file storage across Rails nodes, but NFS introduces a single point of failure and performance bottleneck. The recommended approach is to eliminate NFS entirely by using Gitaly Cluster for repositories and object storage for everything else. Another common failure mode is database failover during peak load: when Patroni promotes a new primary, PgBouncer must redirect connections, and there is a brief period (typically 10-30 seconds) where writes fail. Applications and CI/CD pipelines must tolerate this transient failure. Also, Gitaly Cluster's strong consistency mode (required for data safety) adds latency to Git push operations because the write must be replicated before being acknowledged. Test the latency impact before enabling strong consistency on a high-traffic instance.
Code Example
# GitLab HA Architecture Overview
# Reference: 5K user reference architecture
# ---------------------------------------------------
# Terraform module for GitLab HA on AWS
# ---------------------------------------------------
# Application load balancer for GitLab Rails
# resource "aws_lb" "gitlab_external" {
# name = "gitlab-external-lb"
# internal = false
# load_balancer_type = "application"
# subnets = var.public_subnet_ids
# security_groups = [aws_security_group.gitlab_lb.id]
# }
# ---------------------------------------------------
# Gitaly Cluster configuration (praefect.toml)
# ---------------------------------------------------
# [reconciliation] # Automatic reconciliation settings
# scheduling_interval = "5m" # Check for inconsistencies every 5 minutes
#
# [[virtual_storage]] # Define a virtual storage
# name = "default" # Virtual storage name
# default_replication_factor = 3 # Replicate to 3 nodes
#
# [[virtual_storage.node]] # Gitaly node 1
# storage = "gitaly-1"
# address = "tcp://gitaly-1.internal:8075"
# [[virtual_storage.node]] # Gitaly node 2
# storage = "gitaly-2"
# address = "tcp://gitaly-2.internal:8075"
# [[virtual_storage.node]] # Gitaly node 3
# storage = "gitaly-3"
# address = "tcp://gitaly-3.internal:8075"
# ---------------------------------------------------
# Patroni configuration for PostgreSQL HA
# ---------------------------------------------------
# scope: gitlab-patroni # Patroni cluster name
# name: pg-node-1 # This node's name
#
# restapi:
# listen: 0.0.0.0:8008 # Patroni REST API port
#
# postgresql:
# listen: 0.0.0.0:5432 # PostgreSQL listen port
# data_dir: /var/lib/postgresql/14/main # Data directory
# parameters:
# max_connections: 300 # Connection limit
# shared_buffers: 8GB # Shared memory for caching
# max_wal_senders: 10 # Maximum WAL replication connections
# max_replication_slots: 10 # Maximum replication slots
# ---------------------------------------------------
# Monitoring: Prometheus alerting rules for GitLab HA
# ---------------------------------------------------
# File: gitlab-ha-alerts.yml
groups:
- name: gitlab-ha-alerts # Alert group name
rules:
# Alert on PostgreSQL replication lag
- alert: PostgreSQLReplicationLag # Alert name
expr: pg_replication_lag > 30 # Trigger when lag exceeds 30 seconds
for: 5m # Must persist for 5 minutes
labels:
severity: critical # Critical severity
annotations:
summary: "PostgreSQL replication lag exceeds 30s" # Alert summary
# Alert on Gitaly node down
- alert: GitalyNodeDown # Alert name
expr: up{job="gitaly"} == 0 # Trigger when Gitaly is unreachable
for: 1m # Must persist for 1 minute
labels:
severity: critical # Critical severity
annotations:
summary: "Gitaly node {{ $labels.instance }} is down" # Alert summary
# Alert on Sidekiq queue depth
- alert: SidekiqQueueBacklog # Alert name
expr: sidekiq_queue_size > 10000 # Trigger on large queue backlog
for: 10m # Must persist for 10 minutes
labels:
severity: warning # Warning severity
annotations:
summary: "Sidekiq queue {{ $labels.name }} has {{ $value }} jobs" # Alert summary
# Alert on high Rails error rate
- alert: GitLabHighErrorRate # Alert name
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.01 # 1% error rate
for: 5m # Must persist for 5 minutes
labels:
severity: critical # Critical severity
annotations:
summary: "GitLab Rails 5xx error rate exceeds 1%" # Alert summary
# ---------------------------------------------------
# Health check script for GitLab HA components
# ---------------------------------------------------
# #!/bin/bash
# Check GitLab Rails health
curl -sf https://gitlab.acme.com/-/readiness | jq . # Query readiness endpoint
# Check Gitaly cluster health
curl -sf https://gitlab.acme.com/-/readiness?all=1 | jq '.gitaly_check' # Query Gitaly check
# Check PostgreSQL primary via Patroni API
curl -sf http://pg-node-1.internal:8008/primary | jq . # Query Patroni primary
# Check Sidekiq queue status
curl -sf https://gitlab.acme.com/api/v4/sidekiq/queue_metrics \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" | jq . # Query Sidekiq metricsInterview Tip
A junior engineer typically says 'run multiple instances behind a load balancer' without understanding the stateful components that require special HA treatment. Demonstrate architectural knowledge by walking through each tier: stateless Rails nodes behind a load balancer, Gitaly Cluster with Praefect for repository storage replication, PostgreSQL with Patroni for database HA, Redis with Sentinel for cache and queue HA, and object storage for large binary data. Discuss specific failure modes: database failover latency during Patroni promotion, Gitaly replication lag with strong versus eventual consistency, and NFS elimination as a best practice. Mention monitoring indicators: replication lag, Sidekiq queue depth, error rates, and Gitaly RPC latency. This shows you can operate GitLab as a production platform, not just use it as a developer.
💬 Comments
Quick Answer
The .gitlab-ci.yml file is a YAML configuration file placed at the root of a GitLab repository that defines the CI/CD pipeline. It specifies stages, jobs, scripts, and rules that GitLab Runner executes automatically whenever code is pushed or a merge request is opened.
Detailed Answer
Think of .gitlab-ci.yml as the recipe card in a professional kitchen. The head chef writes down every dish that needs to be prepared (jobs), groups them into courses (stages), and specifies the exact steps for each dish (scripts). When a new order comes in (a git push), the kitchen staff (GitLab Runners) pick up the recipe card and execute every step in the correct sequence without anyone having to shout instructions.
The .gitlab-ci.yml file is the single source of truth for your entire CI/CD pipeline in GitLab. Unlike Jenkins, which stores pipeline configuration on the server, GitLab keeps the pipeline definition inside the repository alongside the application code. This means your pipeline is version-controlled, reviewed through merge requests, and evolves with your codebase. The file uses YAML syntax to define stages (ordered phases like build, test, deploy), jobs (individual tasks within stages), and scripts (shell commands each job executes). Every time a commit is pushed to the repository or a merge request is created, GitLab reads this file from the committed branch and creates a pipeline instance with the defined jobs.
Internally, when GitLab detects a new commit, the CI/CD subsystem parses .gitlab-ci.yml through a linter that validates the YAML syntax and the CI-specific schema. It resolves any includes (references to other YAML files, templates, or remote URLs), expands variables, evaluates rules and only/except conditions to determine which jobs should run, and then creates pipeline and job records in the GitLab database. Each job is placed into a queue, and GitLab Runners poll the server for available jobs matching their tags. The Runner pulls the job specification, clones the repository at the correct commit SHA, executes the before_script, script, and after_script sections in a shell or Docker container, streams logs back to the GitLab web interface in real time, and reports the job status (success, failed, or canceled) back to the server. The pipeline progresses through stages sequentially: all jobs in a stage must succeed before the next stage begins, unless allow_failure is set.
In production environments, teams structure their .gitlab-ci.yml to mirror their software delivery lifecycle. A typical configuration for a microservice like order-processing-service might have stages for lint, build, test, security-scan, staging-deploy, and production-deploy. Teams use the include keyword to import shared CI templates from a central repository, ensuring consistency across dozens of services. For example, a platform team maintains a ci-templates project with reusable job definitions for Docker builds, Kubernetes deployments, and SonarQube scans. Each service includes these templates and only overrides the variables specific to their application. This template-driven approach prevents configuration drift and makes it easy to roll out pipeline improvements across the entire organization.
A common gotcha for beginners is indentation errors in YAML, which can silently change the meaning of your configuration or cause parse failures. Always validate your .gitlab-ci.yml using GitLab's built-in CI Lint tool (found under CI/CD > Pipelines > CI Lint in the project menu) before committing. Another frequent mistake is defining jobs without specifying which stage they belong to; such jobs default to the test stage, which can lead to confusing pipeline behavior. Always explicitly assign every job to a stage.
Code Example
# Define the ordered stages for the pipeline
stages:
- build # First stage: compile the application
- test # Second stage: run automated tests
- deploy # Third stage: deploy to an environment
# Job to build the application Docker image
build-app:
stage: build # Assign this job to the build stage
image: docker:24.0 # Use the Docker image for building
script:
- docker build -t order-service:$CI_COMMIT_SHORT_SHA . # Build with commit SHA tag
tags:
- docker # Run on runners tagged 'docker'
# Job to run unit tests
unit-tests:
stage: test # Assign this job to the test stage
image: node:20-alpine # Use Node.js for running tests
script:
- npm ci # Install dependencies from lockfile
- npm run test:unit # Execute the unit test suite
# Job to deploy to the staging environment
deploy-staging:
stage: deploy # Assign this job to the deploy stage
script:
- kubectl apply -f k8s/staging/ # Apply Kubernetes manifests
environment:
name: staging # Register the staging environment in GitLab
only:
- main # Only deploy when pushing to the main branchInterview Tip
A junior engineer typically describes .gitlab-ci.yml as 'the file that runs tests' without explaining its broader significance. Distinguish yourself by explaining that it is the single source of truth for the entire delivery pipeline, version-controlled alongside the code it deploys. Mention the include keyword for importing shared templates from a central repository, which is how large organizations achieve pipeline consistency across hundreds of microservices. Reference the CI Lint tool for validation and explain that GitLab parses this file on every commit, evaluates rules to determine which jobs run, and queues them for Runners. This demonstrates you understand the architecture, not just the syntax.
◈ Architecture Diagram
┌─────────────────────────────────────────┐
│ .gitlab-ci.yml (in repo) │
└──────────────────┬──────────────────────┘
│ git push
▼
┌─────────────────────────────────────────┐
│ GitLab CI/CD Engine │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ Parse │→│ Evaluate │→│ Create │ │
│ │ YAML │ │ Rules │ │ Pipeline│ │
│ └──────────┘ └──────────┘ └────┬────┘ │
└─────────────────────────────────┼───────┘
▼
┌────────────┬────────────┬────────────┐
│ Stage 1 │ Stage 2 │ Stage 3 │
│ build │ test │ deploy │
│ ┌──────┐ │ ┌──────┐ │ ┌──────┐ │
│ │build │ │ │unit │ │ │deploy│ │
│ │-app │ │ │-tests│ │ │-stg │ │
│ └──────┘ │ └──────┘ │ └──────┘ │
└────────────┴────────────┴────────────┘💬 Comments
Quick Answer
Stages define the sequential phases of a GitLab pipeline, such as build, test, and deploy. All jobs within the same stage run in parallel, but stages themselves execute in the order they are listed. A stage only begins after all jobs in the previous stage have completed successfully.
Detailed Answer
Think of stages like the assembly line in a car factory. The first station welds the chassis (build stage), the second station installs the engine and electronics (test stage), and the third station does the final paint and quality inspection (deploy stage). Workers at the same station can work on different parts of the car simultaneously (parallel jobs within a stage), but the car cannot move to the next station until all workers at the current station have finished. If one worker finds a defect, the entire line stops.
Stages in GitLab CI/CD are defined in the stages keyword at the top of your .gitlab-ci.yml file as an ordered list. Each job in the pipeline is assigned to exactly one stage via the stage keyword. When a pipeline is created, GitLab organizes all jobs by their stage assignment and executes them according to the stage order. Jobs within the same stage run in parallel, limited only by the number of available Runners. For example, if you have three test jobs (unit-tests, integration-tests, lint-check) all assigned to the test stage, and you have three idle Runners, all three jobs start simultaneously. The next stage (say deploy) will not begin until all three test jobs complete successfully. If any job fails, subsequent stages are skipped by default and the pipeline is marked as failed.
Internally, GitLab maintains a state machine for each pipeline. When a pipeline is created, all jobs start in the created state. As Runners become available, jobs in the first stage transition to pending and then running. When a job finishes, it moves to success or failed. GitLab's pipeline processor evaluates the overall stage status: if all jobs in the current stage are in a terminal state (success or allowed failure), it transitions jobs in the next stage to pending. The needs keyword can override this strict stage ordering by creating direct dependencies between jobs, enabling a Directed Acyclic Graph (DAG) pipeline where a job starts as soon as its specific dependencies finish, regardless of whether other jobs in the previous stage are still running. This is an optimization for pipelines where some jobs do not depend on every job in the prior stage.
In production, a well-designed stage structure for a service like payment-gateway-api typically includes five to seven stages: lint (code style checks), build (compile code and create Docker images), test (unit, integration, and end-to-end tests), security (SAST, DAST, dependency scanning), staging-deploy (deploy to a pre-production environment), acceptance (run smoke tests against staging), and production-deploy (deploy to live infrastructure). Teams often add a cleanup stage at the end to tear down temporary test environments or remove unused Docker images. The stage structure should mirror the logical phases of your delivery process, creating a clear visual representation in the GitLab pipeline view that anyone on the team can understand at a glance.
A key gotcha is that if you do not define a stages list in your .gitlab-ci.yml, GitLab uses five default stages: .pre, build, test, deploy, and .post. Jobs without an explicit stage assignment are placed in the test stage by default. This can be confusing when a job you intended to run during deployment actually runs during testing. Another common mistake is having too many sequential stages with single jobs, which eliminates parallelism and makes the pipeline unnecessarily slow. If jobs do not depend on each other, place them in the same stage so they run in parallel, or use the needs keyword for DAG pipelines.
Code Example
# Define five stages that execute in this exact order
stages:
- lint # First: check code style and formatting
- build # Second: compile and package the application
- test # Third: run all automated tests
- security # Fourth: run security scans
- deploy # Fifth: deploy to target environment
# These two lint jobs run in PARALLEL (same stage)
eslint-check:
stage: lint # Runs in the lint stage
image: node:20-alpine # Use Node.js image
script:
- npx eslint src/ # Check JavaScript code style
yaml-lint:
stage: lint # Also runs in the lint stage (parallel with eslint)
image: python:3.12-slim # Use Python image for yamllint
script:
- pip install yamllint # Install the YAML linter
- yamllint .gitlab-ci.yml # Validate pipeline YAML
# Build job waits for ALL lint jobs to pass
build-image:
stage: build # Runs after lint stage completes
image: docker:24.0 # Use Docker-in-Docker
script:
- docker build -t payment-gateway:$CI_COMMIT_SHA . # Build the Docker image
# Test job starts only after build succeeds
integration-tests:
stage: test # Runs after build stage completes
script:
- ./run-integration-tests.sh # Execute integration test suiteInterview Tip
A junior engineer typically lists stages without explaining the execution model. Demonstrate deeper understanding by explaining that jobs within a stage run in parallel while stages themselves run sequentially, and that this creates a trade-off between safety and speed. Mention the needs keyword for DAG pipelines that can bypass strict stage ordering when jobs have specific rather than stage-wide dependencies. Describe a real stage structure you have used, such as lint, build, test, security, deploy, and explain why each stage exists. Interviewers want to see that you can design a pipeline, not just read one.
◈ Architecture Diagram
┌──────────────────────────────────────────────┐ │ Pipeline Execution │ ├──────────────────────────────────────────────┤ │ Stage: lint (parallel) │ │ ┌───────────┐ ┌───────────┐ │ │ │ eslint │ │ yaml-lint │ │ │ │ ✓ pass │ │ ✓ pass │ │ │ └───────────┘ └───────────┘ │ │ │ │ │ │ └──────┬───────┘ │ │ ▼ │ │ Stage: build (sequential gate) │ │ ┌───────────────┐ │ │ │ build-image │ │ │ │ ✓ pass │ │ │ └───────┬───────┘ │ │ ▼ │ │ Stage: test (parallel) │ │ ┌───────────┐ ┌───────────┐ │ │ │ unit │ │integration│ │ │ │ ✓ pass │ │ ✓ pass │ │ │ └───────────┘ └───────────┘ │ │ │ │ │ │ └──────┬───────┘ │ │ ▼ │ │ Stage: deploy │ │ ┌───────────────┐ │ │ │ deploy-staging│ │ │ └───────────────┘ │ └──────────────────────────────────────────────┘
💬 Comments
Quick Answer
Jobs are the fundamental building blocks of a GitLab pipeline. Each job is an independent unit of work that runs a series of shell commands (scripts) on a GitLab Runner. Jobs are defined as top-level keys in .gitlab-ci.yml with properties like stage, image, script, artifacts, and rules that control their behavior.
Detailed Answer
Think of jobs like individual tasks on a project manager's task board. Each task has a clear name (build-frontend, run-unit-tests), is assigned to a specific phase of the project (stage), has step-by-step instructions (script), and might produce deliverables (artifacts). A team member (GitLab Runner) picks up a task, follows the instructions, and reports back whether it passed or failed. Multiple team members can work on different tasks simultaneously if they are in the same project phase.
A job in GitLab CI/CD is defined as a top-level YAML key in .gitlab-ci.yml. Any top-level key that is not a reserved keyword (like stages, variables, include, workflow, or default) is treated as a job definition. Each job must contain at least a script keyword, which is a list of shell commands to execute. Beyond the script, jobs support many configuration options: stage assigns the job to a pipeline phase, image specifies the Docker image to run the job in, tags select which Runners can execute the job, variables set environment variables, before_script and after_script run commands before and after the main script, artifacts define files to preserve after the job finishes, cache specifies directories to cache between pipeline runs for faster execution, and rules or only/except control when the job should be included in the pipeline.
When a Runner picks up a job, it executes a well-defined sequence. First, it prepares the execution environment by pulling the specified Docker image or setting up the shell. Then it clones the repository at the exact commit SHA that triggered the pipeline. It restores any cached files from previous runs. It runs the before_script commands, which are commonly used for setup tasks like installing dependencies or logging into registries. Then it executes the main script commands sequentially; if any command returns a non-zero exit code, the job fails immediately. After the main script (regardless of success or failure), it runs after_script commands, which are useful for cleanup tasks. Finally, it uploads any files matching the artifacts paths to the GitLab server and reports the job status. The entire execution log is streamed to the GitLab web interface in real time, where developers can watch the output and debug failures.
In production, teams design jobs to be focused and single-purpose. Rather than one monolithic test job, a mature pipeline for an application like notification-service might have separate jobs: lint-python for code style, type-check for mypy validation, unit-tests with pytest, integration-tests against a database service, build-docker to create the container image, and deploy-k8s to roll it out. Each job runs in its own isolated container, ensuring clean environments and reproducible results. Jobs use the services keyword to spin up dependent containers like PostgreSQL or Redis during testing. The allow_failure keyword lets non-critical jobs (like experimental linters) fail without blocking the pipeline. The retry keyword automatically retries jobs that fail due to transient issues like network timeouts.
A common beginner gotcha is using reserved keywords as job names. Names like image, services, stages, variables, cache, before_script, and after_script are reserved by GitLab and cannot be used as job names. If you accidentally use one, GitLab will either throw a parsing error or silently misinterpret your configuration. Another mistake is forgetting that each job runs in a fresh environment; files created in one job are not available in another unless you explicitly pass them using artifacts or the cache. This isolation is a feature, not a bug, but it surprises developers who expect state to persist between jobs.
Code Example
# Define pipeline stages
stages:
- build # Compilation and packaging stage
- test # Testing and validation stage
# A job to build the application
build-notification-service:
stage: build # This job belongs to the build stage
image: golang:1.22 # Use Go 1.22 Docker image
before_script:
- go mod download # Download dependencies before building
script:
- go build -o notification-service ./cmd/server # Compile the Go binary
artifacts:
paths:
- notification-service # Save the compiled binary
expire_in: 1 hour # Auto-delete the artifact after 1 hour
tags:
- linux # Run on runners tagged 'linux'
# A job to run unit tests
unit-tests:
stage: test # This job belongs to the test stage
image: golang:1.22 # Same Go image for consistency
services:
- redis:7-alpine # Spin up Redis as a test dependency
variables:
REDIS_HOST: redis # Set the hostname for the Redis service
script:
- go test ./... -v -cover # Run all tests with verbose output and coverage
retry:
max: 2 # Retry up to 2 times on failure
when:
- runner_system_failure # Only retry on Runner infrastructure issues
allow_failure: false # This job must pass for the pipeline to succeedInterview Tip
A junior engineer typically defines a job with just a script and nothing else. Show maturity by explaining the full job lifecycle: environment preparation, repository clone, cache restore, before_script, script, after_script, artifact upload, and status reporting. Mention that each job runs in complete isolation, so you need artifacts to pass files between jobs. Discuss practical keywords like retry for handling flaky infrastructure, allow_failure for non-blocking jobs, and services for spinning up databases during testing. Interviewers value candidates who can explain why jobs are isolated and how to work with that constraint rather than against it.
◈ Architecture Diagram
┌─────────────────────────────────────────┐ │ Job Execution Lifecycle │ ├─────────────────────────────────────────┤ │ 1. ┌──────────────────────┐ │ │ │ Pull Docker Image │ │ │ │ (image: golang:1.22) │ │ │ └──────────┬───────────┘ │ │ ▼ │ │ 2. ┌──────────────────────┐ │ │ │ Clone Repository │ │ │ │ at commit SHA │ │ │ └──────────┬───────────┘ │ │ ▼ │ │ 3. ┌──────────────────────┐ │ │ │ Restore Cache │ │ │ └──────────┬───────────┘ │ │ ▼ │ │ 4. ┌──────────────────────┐ │ │ │ Run before_script │ │ │ └──────────┬───────────┘ │ │ ▼ │ │ 5. ┌──────────────────────┐ │ │ │ Run script │──→ ✓ or ✗ │ │ └──────────┬───────────┘ │ │ ▼ │ │ 6. ┌──────────────────────┐ │ │ │ Run after_script │ │ │ └──────────┬───────────┘ │ │ ▼ │ │ 7. ┌──────────────────────┐ │ │ │ Upload Artifacts │ │ │ └──────────────────────┘ │ └─────────────────────────────────────────┘
💬 Comments
Quick Answer
Artifacts are files generated by a job that are uploaded to the GitLab server and can be downloaded by subsequent jobs, the GitLab UI, or the API. They are the primary mechanism for passing build outputs, test results, and compiled binaries between pipeline stages since each job runs in an isolated environment.
Detailed Answer
Think of artifacts like the handoff baton in a relay race. Runner one (the build job) sprints their leg and passes the baton (the compiled binary) to runner two (the test job). Without the baton, the next runner has nothing to work with and the race fails. In GitLab, each job runs in its own isolated lane, so artifacts are the only reliable way to pass the baton from one job to the next.
Artifacts are defined within a job using the artifacts keyword. The paths property specifies which files or directories to upload after the job completes. GitLab compresses these files and stores them on the server (or in configured object storage like S3 or GCS). When a downstream job in a later stage starts, GitLab automatically downloads artifacts from all successful jobs in previous stages, extracting them into the job's working directory. You can control artifact retention with expire_in (e.g., '1 week', '30 days') to prevent storage from growing indefinitely. The when property determines under which conditions artifacts are uploaded: on_success (default, only when the job passes), on_failure (only when the job fails, useful for uploading debug logs), or always (regardless of outcome).
Internally, when a job finishes its script execution, the GitLab Runner scans the working directory for files matching the artifacts paths patterns. It creates a zip archive of the matched files and uploads it to the GitLab server via the API. The server stores the archive and associates it with the specific job and pipeline. When a subsequent job starts, the Runner sends a request to the GitLab server asking for artifacts from dependent jobs. The server responds with download URLs, the Runner fetches and extracts the archives into the working directory, and the job's script can then access those files as if they were created locally. For large artifacts, GitLab supports direct upload to object storage (AWS S3, Google Cloud Storage, Azure Blob Storage) to reduce load on the GitLab server itself.
In production, artifacts serve multiple purposes beyond just passing files between jobs. Teams use the reports keyword to upload structured test results (JUnit XML), code coverage data, SAST and DAST security scan results, and dependency scanning reports. GitLab parses these report artifacts and displays them directly in the merge request interface: test failures appear as annotations on the merge request, code coverage percentages are shown on the diff view, and security vulnerabilities are listed in a dedicated security tab. A typical production pipeline for inventory-management-api would have the build job produce a Docker image reference as an artifact, the test job produce JUnit XML reports, and the security-scan job produce a SAST report. All of these integrate seamlessly into the merge request review experience.
A key gotcha is artifact size and retention. By default, artifacts never expire in many GitLab installations, which can consume enormous amounts of disk space over time. Always set expire_in on your artifacts to a reasonable duration: compiled binaries might expire in one hour since they are only needed for the current pipeline, while test reports might be kept for 30 days for debugging purposes. Another common mistake is defining overly broad paths like '.' which uploads the entire repository including the .git directory and node_modules. Be specific about which files you need, and use the exclude keyword to filter out unnecessary files.
Code Example
# Define pipeline stages
stages:
- build # Build stage produces artifacts
- test # Test stage consumes build artifacts
- report # Report stage consumes test artifacts
# Build job creates a compiled binary
build-api:
stage: build # Assign to the build stage
image: golang:1.22 # Use Go image for compilation
script:
- go build -o inventory-api ./cmd/api # Compile the API binary
artifacts:
paths:
- inventory-api # Upload the compiled binary
expire_in: 1 hour # Keep artifact for 1 hour only
# Test job receives the binary from build-api
run-tests:
stage: test # Assign to the test stage
image: golang:1.22 # Use same Go image
script:
- ls -la inventory-api # Verify artifact was downloaded
- go test ./... -v -coverprofile=coverage.out # Run tests with coverage
artifacts:
paths:
- coverage.out # Upload coverage data for the next stage
reports:
junit: test-results.xml # Upload JUnit report for MR display
when: always # Upload artifacts even if tests fail
expire_in: 30 days # Keep test reports for 30 days
# Report job uses the coverage artifact
coverage-report:
stage: report # Assign to the report stage
script:
- go tool cover -func=coverage.out # Display coverage summary
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/' # Regex to extract coverage %Interview Tip
A junior engineer typically mentions artifacts as 'files passed between jobs' but misses the richer capabilities. Elevate your answer by explaining the reports keyword, which lets you upload JUnit test results, coverage data, and security scan outputs that GitLab renders directly in the merge request UI. Discuss expire_in as a storage management practice, the when: always option for uploading debug logs from failed jobs, and the performance consideration of artifact size. Interviewers appreciate candidates who understand that artifacts are not just files on disk but integrations that enhance the code review experience.
◈ Architecture Diagram
┌─────────────┐ artifacts ┌─────────────┐
│ build-api │ ──────────────→ │ run-tests │
│ (stage: │ inventory-api │ (stage: │
│ build) │ │ test) │
└─────────────┘ └──────┬──────┘
│
artifacts:
coverage.out
test-results.xml
│
┌───────────────┬┘
▼ ▼
┌─────────────┐ ┌─────────────┐
│ coverage- │ │ Merge │
│ report │ │ Request UI │
│ (stage: │ │ ┌─────────┐ │
│ report) │ │ │ JUnit │ │
└─────────────┘ │ │ Results │ │
│ └─────────┘ │
└─────────────┘💬 Comments
Quick Answer
A merge request (MR) is GitLab's mechanism for proposing, reviewing, and merging code changes from one branch into another. While conceptually similar to GitHub's pull requests, GitLab merge requests include built-in CI/CD pipeline integration, approval rules, merge trains, and environment tracking that are native to the platform rather than bolt-on features.
Detailed Answer
Think of a merge request like a formal proposal at a city council meeting. A council member (developer) drafts a proposal (code changes on a branch), submits it for public review (opens an MR), other members ask questions and suggest amendments (code review comments), the proposal undergoes an impact assessment (CI/CD pipeline runs tests and security scans), the required committee chairs sign off (approvals), and finally the proposal is enacted into law (merged into the main branch). The entire debate is recorded in the minutes (MR history) for future reference.
A merge request in GitLab is created from a source branch targeting a destination branch. The MR page displays the diff of all changes, a discussion thread for general comments, the ability to leave inline comments on specific lines of code, the status of associated CI/CD pipelines, approval status, and links to related issues. Unlike GitHub pull requests, which were later enhanced with checks and review features, GitLab merge requests were designed from the start to integrate tightly with the CI/CD pipeline. The pipeline status is prominently displayed on the MR, and you can configure the project so that MRs cannot be merged unless the pipeline succeeds. GitLab also supports merge request pipelines, which are pipelines that run specifically in the context of the merge result, testing what the code will look like after merging rather than just the source branch in isolation.
Internally, when you create a merge request, GitLab stores it as a database record linking the source branch, target branch, author, description, and metadata. GitLab computes the diff between the source and target branches and renders it in the web interface. If the project has CI/CD configured, GitLab triggers a pipeline for the source branch (or a merged-result pipeline if configured). Reviewers can approve the MR, and GitLab tracks how many approvals have been given versus how many are required. The approval rules engine supports sophisticated policies: require two approvals from the backend team if backend files changed, require one approval from the security team if Dockerfile or dependency files changed. When all conditions are met (pipeline passed, approvals obtained, no unresolved discussions if that setting is enabled), the merge button becomes active.
In production, merge requests are the central workflow for teams using GitLab. A team working on auth-service would follow this pattern: a developer creates a branch like feature/oauth2-pkce-flow, pushes commits, and opens an MR targeting main. The MR description references the issue with Closes #187, linking the MR to the issue tracker. The pipeline runs lint, build, test, and security scan stages. Reviewers from the auth-team are automatically assigned based on CODEOWNERS rules (yes, GitLab has its own CODEOWNERS equivalent in the CODEOWNERS file). Merge trains allow multiple approved MRs to be queued and merged sequentially, with each MR tested against the combined result of all MRs ahead of it in the queue. This prevents the classic problem of two individually passing MRs that break when combined.
A common gotcha is confusing the terminology when interviewing at companies that use GitLab. Never call them 'pull requests' in a GitLab context; they are 'merge requests' and the distinction matters because it signals platform fluency. Another pitfall is not using merge request templates, which GitLab supports through .gitlab/merge_request_templates/ directory in your repository. Templates ensure developers provide context about their changes, testing performed, and deployment considerations. Without templates, MR descriptions tend to be empty or unhelpful, making code review less effective.
Code Example
# Create a feature branch for the merge request git checkout -b feature/oauth2-pkce-flow # Create and switch to the branch # Make changes and commit git add src/auth/oauth2.py # Stage the modified file git commit -m "Add PKCE flow support for OAuth2 clients" # Commit with descriptive message # Push the branch to GitLab git push -u origin feature/oauth2-pkce-flow # Push and set upstream tracking # Create a merge request using the GitLab push option git push -o merge_request.create \ -o merge_request.title="Add OAuth2 PKCE flow" \ -o merge_request.description="Closes #187" \ -o merge_request.target=main \ -o merge_request.remove_source_branch # Auto-delete branch after merge # Or use the glab CLI to create a merge request glab mr create --title "Add OAuth2 PKCE flow" \ --description "Closes #187" \ --target-branch main \ --assignee @devlead # Assign the MR to the dev lead # List open merge requests glab mr list --state opened # Show all open MRs in the project # Approve a merge request glab mr approve 42 # Approve MR number 42
Interview Tip
A junior engineer typically treats merge requests as identical to GitHub pull requests. Differentiate yourself by highlighting GitLab-specific features: merge request pipelines that test the merged result before actually merging, merge trains that queue and test MRs sequentially to prevent integration failures, push options that let you create MRs directly from the git push command without visiting the web UI, and approval rules that can require different reviewers based on which files changed. Always use the correct terminology; saying 'pull request' in a GitLab interview signals unfamiliarity with the platform, which undermines your credibility on CI/CD questions.
◈ Architecture Diagram
┌──────────────┐ push ┌──────────────┐ create ┌──────────────┐
│ Developer │ ───────→ │ Feature │ ────────→ │ Merge │
│ Local │ │ Branch │ │ Request │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
┌─────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐┌───────────┐┌────────────┐
│ Pipeline ││ Code ││ Approval │
│ ✓ build ││ Review ││ Rules │
│ ✓ test ││ Comments ││ 2/2 ✓ │
│ ✓ scan ││ Threads ││ │
└─────┬──────┘└─────┬─────┘└─────┬──────┘
│ │ │
└──────┬──────┘────────────┘
▼
┌────────────┐
│ Merge │
│ to main │
└────────────┘💬 Comments
Quick Answer
The GitLab web UI provides a comprehensive project dashboard with sections for repository browsing, merge requests, CI/CD pipelines, issue tracking, container registry, environments, and project settings. The pipeline visualization shows stages, jobs, and their statuses in real time, with drill-down access to job logs and artifacts.
Detailed Answer
Think of the GitLab web interface like the control panel of a modern airplane cockpit. Every instrument has a specific purpose: the altimeter shows your current altitude (pipeline status), the radar shows obstacles ahead (failing tests), the fuel gauge tracks resources (Runner availability), and the navigation display shows your route (deployment environments). A pilot does not need to memorize every value because the cockpit is designed to surface the most important information prominently and allow drill-down when needed.
The GitLab project page is organized into a left sidebar with major sections. The Repository section lets you browse files, view commit history, compare branches, and see the repository graph. The Issues section manages bug reports and feature requests with boards, labels, and milestones. The Merge Requests section lists all open, merged, and closed MRs with filtering and sorting options. The CI/CD section is where pipelines, jobs, schedules, and pipeline editor live. The Deployments section shows environments (staging, production) and their deployment history. The Packages and Registries section hosts container images, npm packages, Maven artifacts, and other package types. The Settings section controls project visibility, CI/CD variables, protected branches, merge request approvals, webhooks, and integrations.
The pipeline visualization is one of GitLab's strongest UI features. When you navigate to CI/CD > Pipelines and click on a specific pipeline, GitLab renders a stage-by-stage diagram showing each job as a node with its status (pending, running, passed, failed, canceled, skipped). Jobs within the same stage appear in a vertical column, and stages flow left to right. Clicking on any job opens the real-time log viewer, which streams the Runner's output as the job executes. You can search within logs, download them as text files, and expand or collapse log sections that were defined using the collapsible section markers in your CI script. The job page also shows which Runner executed the job, how long it took, any artifacts produced, and retry or cancel buttons.
In production, teams rely heavily on several UI features for daily operations. The merge request widget shows pipeline status, approval state, code coverage changes, security scan findings, and test report summaries all in one place, which reviewers use to make merge decisions without leaving the MR page. The Environments page under Deployments shows which version of the code is currently deployed to each environment (dev, staging, production) with one-click rollback buttons. The CI/CD > Pipeline Editor provides a browser-based YAML editor with syntax validation, auto-completion, and a visualization preview of your pipeline structure, which is invaluable when modifying complex .gitlab-ci.yml files. The repository file browser includes a Web IDE option that lets you edit files and commit directly from the browser, which is convenient for documentation updates or quick configuration changes.
A common gotcha for beginners is not knowing where to find specific information. The CI/CD variables page (Settings > CI/CD > Variables) is the most frequently missed setting; this is where you store secrets like Docker registry credentials, API keys, and deployment tokens that your pipeline needs. Another overlooked feature is the pipeline's needs visualization, accessible via the 'Needs' tab on the pipeline page, which shows the DAG dependency graph when you use the needs keyword. Beginners also miss the CI Lint tool under CI/CD > Pipelines, which validates your .gitlab-ci.yml syntax before you commit, saving time on broken pipeline runs.
Code Example
# Navigate the GitLab UI using keyboard shortcuts
# Press '?' on any GitLab page to see all shortcuts
# Press 'g' then 'p' to go to the project page
# Press 'g' then 'i' to go to the issues list
# Press 'g' then 'm' to go to merge requests
# Use the Pipeline Editor for visual YAML editing
# Navigate to: CI/CD > Pipeline Editor
# Paste this and click 'Visualize' to preview
stages:
- build # First stage shown on the left
- test # Second stage shown in the middle
- deploy # Third stage shown on the right
build-job:
stage: build # This job appears under the build column
script:
- echo "Building the auth-service" # Simple build command
test-job:
stage: test # This job appears under the test column
script:
- echo "Running tests" # Simple test command
deploy-job:
stage: deploy # This job appears under the deploy column
script:
- echo "Deploying to staging" # Simple deploy command
environment:
name: staging # Creates an entry on the Environments page
url: https://staging.example.com # Links to the staging URLInterview Tip
A junior engineer typically only mentions the pipeline view when discussing the GitLab UI. Show broader platform knowledge by walking through the key sections: the merge request widget that consolidates pipeline status, approvals, and security findings; the Environments page showing what is deployed where with rollback capability; the Pipeline Editor for visual YAML validation; the CI/CD Variables page for secrets management; and keyboard shortcuts for power-user navigation. Interviewers often ask 'walk me through how you would debug a failed deployment' and they want to hear you describe navigating from the pipeline view to the failing job's logs, checking the Runner details, and examining artifacts. Platform fluency signals real-world experience.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────┐ │ GitLab Project Sidebar │ ├─────────────────────────────────────────────────────┤ │ ┌─────────────┐ │ │ │ Repository │→ Files, Commits, Branches, Tags │ │ ├─────────────┤ │ │ │ Issues │→ Boards, Labels, Milestones │ │ ├─────────────┤ │ │ │ Merge Reqs │→ Open, Merged, Closed, Drafts │ │ ├─────────────┤ │ │ │ CI/CD │→ Pipelines, Jobs, Schedules, Editor │ │ ├─────────────┤ │ │ │ Deployments │→ Environments, Releases │ │ ├─────────────┤ │ │ │ Packages │→ Container Registry, npm, Maven │ │ ├─────────────┤ │ │ │ Settings │→ CI/CD Vars, Protected Branches │ │ └─────────────┘ │ └─────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
GitLab project settings control repository behavior, CI/CD configuration, access permissions, and merge policies. Protected branches prevent unauthorized pushes and force-pushes to critical branches like main, while merge request approval rules define how many reviewers must approve before code can be merged.
Detailed Answer
Think of project settings like the security system and house rules for a shared apartment building. Protected branches are like the locks on the building's main entrance: only residents with the right key (permitted roles) can enter, and nobody can break down the door (force-push). Merge request approvals are like the building's renovation policy: before you can knock down a wall (merge code), you need sign-off from the building manager (lead developer) and a structural engineer (security reviewer). The settings ensure the building stays safe and livable for everyone.
GitLab project settings are accessed through the left sidebar under Settings and are organized into subsections: General (project name, visibility, features), Repository (protected branches, deploy keys, mirroring), CI/CD (runners, variables, pipeline settings), Merge Requests (approval rules, merge methods, merge checks), and more. Protected branches are configured under Settings > Repository > Protected Branches. You select a branch pattern (exact name like main or a wildcard like release-*), then specify who can push and who can merge. Options range from 'No one' (all changes must come through merge requests) to 'Maintainers' or 'Developers and Maintainers'. You can also prevent force pushes, which is critical for maintaining a clean and auditable commit history on production branches.
Internally, when a developer attempts to push directly to a protected branch, GitLab's pre-receive hook checks the user's role against the branch protection configuration. If the user does not have the required permission level, the push is rejected with an error message indicating that the branch is protected. For merge requests targeting a protected branch, GitLab evaluates the approval rules before enabling the merge button. Approval rules can be configured at the project level or at the individual merge request level. Each rule specifies a name, the number of required approvals, and optionally a list of eligible approvers (specific users or groups). GitLab tracks which users have approved and whether their approval is still valid. The 'Prevent approval by author' setting ensures that the person who wrote the code cannot approve their own merge request, enforcing the four-eyes principle.
In production environments, a typical configuration for a critical service like payment-processor would include: main branch protected with push access limited to 'No one' and merge access to 'Maintainers only', a merge request approval rule requiring two approvals from the backend-team group, a second approval rule requiring one approval from the security-team for any MR that modifies Dockerfile or dependency files, the 'Prevent approval by author' setting enabled, the 'Remove all approvals when commits are pushed' setting enabled to prevent post-approval code changes from being merged without re-review, and the merge method set to 'Merge commit with semi-linear history' for a clean main branch history. These settings together create a robust quality gate that catches problems before they reach production.
A key gotcha is not understanding the interaction between group-level and project-level settings. In GitLab Premium and Ultimate tiers, group owners can enforce settings across all projects in the group, and project maintainers cannot weaken these settings (though they can strengthen them). For example, if a group requires two approvals, a project within that group cannot reduce it to one. Another common mistake is protecting branches after the fact without realizing that existing force-push history is not retroactively corrected. Always set up branch protection before the first merge to main. Also, wildcard patterns like release-* protect all matching branches, which is useful but can accidentally protect branches you did not intend if your naming convention is not consistent.
Code Example
# Configure protected branches via the GitLab API
# Protect the main branch - only maintainers can merge
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--data "name=main" \
--data "push_access_level=0" \
--data "merge_access_level=40" \
"https://gitlab.example.com/api/v4/projects/12/protected_branches" # 0=no one, 40=maintainers
# Protect release branches using a wildcard pattern
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--data "name=release-*" \
--data "push_access_level=0" \
--data "merge_access_level=40" \
"https://gitlab.example.com/api/v4/projects/12/protected_branches" # Protect all release-* branches
# Create an approval rule requiring 2 backend team reviews
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data '{"name": "Backend Review", "approvals_required": 2, "group_ids": [15]}' \
"https://gitlab.example.com/api/v4/projects/12/approval_rules" # Group 15 is the backend team
# List current protected branches for verification
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/12/protected_branches" # Verify protection settingsInterview Tip
A junior engineer typically knows that main should be protected but cannot explain the full configuration. Demonstrate depth by discussing specific settings: push access set to 'No one' to force all changes through merge requests, merge access limited to maintainers, approval rules with separate requirements for different teams based on which files are changed, the 'Prevent approval by author' setting for the four-eyes principle, and the 'Remove all approvals when commits are pushed' setting to prevent post-approval sneaky changes. Mention the hierarchy between group-level and project-level settings, showing you understand GitLab's organizational model. Interviewers value candidates who can explain the security rationale behind each setting.
◈ Architecture Diagram
┌───────────────────────────────────────────┐ │ Protected Branch: main │ ├───────────────────────────────────────────┤ │ │ │ Push Access: No one ✗ │ │ Merge Access: Maintainers only ✓ │ │ Force Push: Disabled ✗ │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ Merge Request Required │ │ │ │ │ │ │ │ Approval Rule 1: Backend Review │ │ │ │ → 2 approvals from backend-team │ │ │ │ │ │ │ │ Approval Rule 2: Security Review │ │ │ │ → 1 approval from security-team │ │ │ │ │ │ │ │ ☐ Author cannot approve │ │ │ │ ☐ Reset approvals on new push │ │ │ │ ☐ Pipeline must succeed │ │ │ └─────────────────────────────────────┘ │ │ │ │ developer ──→ feature branch ──→ MR ──→ │ │ ──→ review ──→ approve ──→ merge to main │ └───────────────────────────────────────────┘
💬 Comments
Quick Answer
A GitLab CI/CD pipeline is a collection of jobs organized into stages that execute automatically when code is pushed to the repository. A basic pipeline is triggered by committing a .gitlab-ci.yml file to the project root, after which every subsequent push, merge request, or scheduled event creates a new pipeline instance.
Detailed Answer
Think of a GitLab pipeline like an automated assembly line in a factory that starts rolling the moment a delivery truck arrives. The truck (git push) delivers raw materials (code changes) to the factory entrance (GitLab server). The assembly line (pipeline) kicks into gear automatically, moving the materials through quality inspection (lint), manufacturing (build), testing (test), and shipping (deploy). No human needs to press a start button; the arrival of materials triggers the entire process.
A pipeline in GitLab is the top-level container for all CI/CD work. It consists of stages (ordered phases) and jobs (individual tasks within stages). The simplest possible pipeline requires only a .gitlab-ci.yml file at the repository root with at least one job containing a script keyword. When you push this file to GitLab, the CI/CD engine detects it, parses the configuration, creates a pipeline record, and begins scheduling jobs for execution. Pipelines are triggered by several events: pushing commits to any branch, creating or updating a merge request, on a cron schedule defined in the GitLab UI under CI/CD > Schedules, manually through the 'Run pipeline' button in the web interface, or via the GitLab API. Each pipeline is associated with a specific commit SHA and branch, creating a direct link between code changes and their automated validation.
Internally, when a push event reaches the GitLab server, the webhook processor extracts the branch name and commit SHA. The CI/CD subsystem fetches the .gitlab-ci.yml content from that specific commit (not from the default branch, but from the pushed branch itself). It parses and validates the YAML, resolves any include directives, expands variables, and evaluates workflow rules to determine if a pipeline should be created at all. If the pipeline proceeds, GitLab evaluates each job's rules or only/except conditions against the current context (branch name, whether it is a merge request, whether variables match) to determine which jobs to include. Included jobs are organized by stage, assigned a created status, and stored in the database. GitLab Runners continuously poll the server for pending jobs matching their tags and capabilities. When a Runner picks up a job, it transitions to running, and log output streams back to the GitLab interface in real time.
In production, teams rarely trigger pipelines manually. The standard workflow for a service like search-indexer relies on automatic triggers: developers push to feature branches, which triggers a pipeline running lint, build, and test stages. When they open a merge request, a merge request pipeline runs the same stages plus a security scan. When the MR is merged to main, a production pipeline runs all stages including deployment to staging and, with manual approval gates, deployment to production. Scheduled pipelines run nightly to execute long-running performance tests or dependency vulnerability scans that are too slow for every push. The pipeline trigger token feature allows external systems (like a separate repository or a monitoring alert) to start pipelines via API, enabling cross-project orchestration.
A common gotcha for beginners is expecting the pipeline to run when they first add the .gitlab-ci.yml file but finding that nothing happens. This usually occurs because the project does not have any GitLab Runners available. Shared Runners must be enabled in the project settings, or specific Runners must be registered and assigned to the project. Another mistake is writing a .gitlab-ci.yml that triggers pipelines on every branch including branches that do not need CI, wasting Runner resources. Use the workflow keyword or rules to control which branches trigger pipelines, such as only running the full pipeline on main and merge requests while running a minimal lint-only pipeline on other branches.
Code Example
# A basic pipeline that triggers automatically on every push
stages:
- validate # First stage: check code quality
- build # Second stage: compile the application
- test # Third stage: run automated tests
# Validate code style
lint-code:
stage: validate # Assign to validate stage
image: python:3.12-slim # Use Python image
script:
- pip install flake8 # Install the Python linter
- flake8 src/ --max-line-length=120 # Check code style with custom line length
# Build the Docker image
build-image:
stage: build # Assign to build stage
image: docker:24.0 # Use Docker image
services:
- docker:24.0-dind # Enable Docker-in-Docker service
script:
- docker build -t search-indexer:$CI_COMMIT_SHA . # Build with commit SHA tag
# Run tests
run-tests:
stage: test # Assign to test stage
image: python:3.12-slim # Use Python image for tests
script:
- pip install -r requirements.txt # Install project dependencies
- pytest tests/ -v --tb=short # Run pytest with verbose short traceback
# Trigger a pipeline manually via the API
# curl -X POST -F token=TRIGGER_TOKEN -F ref=main \
# https://gitlab.example.com/api/v4/projects/12/trigger/pipeline # API triggerInterview Tip
A junior engineer typically says 'pipelines run when you push code' without explaining the trigger mechanisms or internal flow. Stand out by describing multiple trigger types: push events, merge request events, scheduled cron triggers, manual runs from the UI, and API triggers for cross-project orchestration. Explain that GitLab reads the .gitlab-ci.yml from the pushed commit (not the default branch), which means each branch can have its own pipeline configuration. Mention the workflow keyword for controlling when pipelines are created, and discuss the common pitfall of having no available Runners. This demonstrates operational understanding beyond basic syntax knowledge.
◈ Architecture Diagram
┌──────────────────────────────────────────────┐ │ Pipeline Trigger Sources │ ├──────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │git push │ │ Merge │ │ Schedule │ │ │ │to branch │ │ Request │ │ (cron) │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ ┌────┴─────┐ ┌────┴─────┐ ┌────┴─────┐ │ │ │ Manual │ │ API │ │ Parent │ │ │ │ (UI) │ │ Trigger │ │ Pipeline │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ └─────────────┼─────────────┘ │ │ ▼ │ │ ┌───────────────────┐ │ │ │ Parse .gitlab- │ │ │ │ ci.yml from │ │ │ │ commit SHA │ │ │ └─────────┬─────────┘ │ │ ▼ │ │ ┌───────────────────┐ │ │ │ Create Pipeline │ │ │ │ validate → build │ │ │ │ → test │ │ │ └───────────────────┘ │ └──────────────────────────────────────────────┘
💬 Comments
Quick Answer
GitLab organizes work into a hierarchy of groups, subgroups, and projects. Groups are containers for related projects and can nest into subgroups up to 20 levels deep. This structure enables shared CI/CD settings, centralized access control, and consistent pipeline templates across multiple projects within an organization.
Detailed Answer
Think of GitLab's organizational hierarchy like the structure of a large corporation. The company itself is the top-level group (acme-corp). Departments are subgroups (engineering, marketing, operations). Teams within departments are deeper subgroups (engineering/backend, engineering/frontend). Individual product repositories are projects within those team subgroups (engineering/backend/payment-api). Just as company-wide policies cascade down to every department and team, GitLab group settings flow down to every nested subgroup and project.
Groups in GitLab are namespaces that contain projects and optionally other subgroups. Every project belongs to exactly one group or personal namespace, and its URL reflects the hierarchy: gitlab.com/acme-corp/engineering/backend/payment-api. Groups provide centralized management for members and access levels: adding a user as a Developer on the engineering group automatically grants Developer access to every project within that group and its subgroups. This inheritance model eliminates the need to manage permissions project by project. Groups can also hold shared Runners, CI/CD variables, deploy tokens, and package registries that all child projects inherit. Labels and milestones defined at the group level appear in all child projects, enabling cross-project issue tracking and planning.
Internally, GitLab implements namespace inheritance through a parent-child relationship stored in the database. Each group has a parent_id (null for top-level groups) and a traversal_ids array that efficiently resolves the full ancestry chain. When evaluating permissions for a user accessing a project, GitLab walks up the namespace tree, checking memberships at each level. The most permissive role from any level in the chain becomes the effective permission. CI/CD variables defined at a group level are injected into pipelines of all child projects, with project-level variables taking precedence over group-level ones if they share the same key. This variable inheritance is a powerful mechanism for distributing shared secrets like Docker registry credentials or cloud provider API keys without configuring them in each project individually.
In production, organizations use groups to mirror their team structure and enforce governance. A typical enterprise setup has a top-level group per business unit (e.g., acme-corp/platform, acme-corp/product, acme-corp/data), with subgroups for teams (acme-corp/platform/sre, acme-corp/platform/security). Each team's subgroup contains their projects. The platform/sre group might contain infrastructure-modules, monitoring-stack, and incident-runbooks. Shared Runners registered at the top-level group are available to all projects, providing a consistent execution environment. Group-level CI/CD variables store shared secrets like DOCKER_REGISTRY_PASSWORD and SONAR_TOKEN, so individual projects do not need to duplicate these. Group-level merge request approval settings enforce minimum review requirements across all projects, and group-level push rules (in Premium tier) enforce commit message formatting or prevent certain file types from being committed.
A common gotcha is overcomplicating the group structure. While GitLab supports up to 20 levels of nesting, most organizations should use two to three levels at most (company > department > team). Deep nesting creates confusing URLs and makes it harder to find projects. Another pitfall is not understanding variable precedence: if the same variable is defined at the instance level, group level, subgroup level, and project level, the project-level value wins. This can lead to confusion when a developer overrides a group variable in their project without realizing it, causing their pipeline to behave differently from others in the same group.
Code Example
# Create a new group using the GitLab API
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data '{"name": "Backend Team", "path": "backend", "parent_id": 5}' \
"https://gitlab.example.com/api/v4/groups" # parent_id 5 is the engineering group
# Create a project within the backend subgroup
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data '{"name": "Payment API", "path": "payment-api", "namespace_id": 12}' \
"https://gitlab.example.com/api/v4/projects" # namespace_id 12 is the backend group
# Set a group-level CI/CD variable shared by all child projects
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--data "key=DOCKER_REGISTRY" \
--data "value=registry.example.com" \
--data "masked=true" \
"https://gitlab.example.com/api/v4/groups/5/variables" # Variable inherited by all projects
# List all projects in a group including subgroups
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/groups/5/projects?include_subgroups=true" # Recursive listing
# Clone a project using its full group path
git clone [email protected]:acme-corp/engineering/backend/payment-api.git # Full namespace pathInterview Tip
A junior engineer typically describes groups as 'folders for projects' without explaining the inheritance model. Show depth by explaining that group membership, CI/CD variables, Runners, labels, and compliance settings all cascade down to child subgroups and projects. Discuss the practical implications: a shared DOCKER_REGISTRY_PASSWORD variable set at the engineering group level is automatically available in every project's pipeline without per-project configuration. Mention variable precedence (project overrides group, group overrides instance) and the governance benefits like group-level approval rules that enforce review policies across all projects. This organizational understanding signals senior-level thinking about platform management.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Top-Level Group: acme-corp │ │ Members: CTO, VP Engineering │ │ Variables: SONAR_TOKEN │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ Subgroup: engineering │ │ │ │ Members: + all engineers │ │ │ │ Variables: + DOCKER_REGISTRY │ │ │ │ │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ │ │ Subgroup: │ │ Subgroup: │ │ │ │ │ │ backend │ │ frontend │ │ │ │ │ │ │ │ │ │ │ │ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ │ │ │ │ │payment- │ │ │ │web-app │ │ │ │ │ │ │ │api │ │ │ │ │ │ │ │ │ │ │ └──────────┘ │ │ └──────────┘ │ │ │ │ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ │ │ │ │ │order- │ │ │ │mobile- │ │ │ │ │ │ │ │service │ │ │ │app │ │ │ │ │ │ │ └──────────┘ │ │ └──────────┘ │ │ │ │ │ └──────────────┘ └──────────────┘ │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
GitLab CI/CD variables are key-value pairs that inject configuration, secrets, and dynamic values into pipeline jobs. They can be defined at the instance, group, project, or pipeline level, and GitLab also provides dozens of predefined variables like CI_COMMIT_SHA and CI_PIPELINE_ID that give jobs context about the current pipeline execution.
Detailed Answer
Think of CI/CD variables like the settings panel on a dishwasher. Instead of hardcoding the water temperature and cycle length into the machine's circuit board, the manufacturer exposes dials and buttons (variables) that let you adjust behavior without rewiring anything. You can set defaults (project-level variables), override them for a special load (pipeline-level variables), and some settings come preset from the factory (predefined variables like CI_COMMIT_SHA). Secrets like the rinse-aid dosage are hidden behind a cover (masked variables) so guests cannot see them.
CI/CD variables in GitLab are key-value pairs available as environment variables inside every job's execution environment. They are defined at multiple levels with a clear precedence hierarchy: pipeline-level variables (set when manually triggering a pipeline) override project-level variables, which override group-level variables, which override instance-level variables. Within the .gitlab-ci.yml file, you can define variables at the global level (applying to all jobs) or at the job level (applying only to that job). Job-level variables override global-level variables of the same name. Variables defined in the GitLab UI under Settings > CI/CD > Variables are injected into every pipeline run and are the recommended location for secrets because they are stored encrypted in the database and never appear in the .gitlab-ci.yml file, which is committed to the repository.
GitLab provides a rich set of predefined variables that are automatically available in every job. CI_COMMIT_SHA contains the full 40-character commit hash, CI_COMMIT_SHORT_SHA is the first eight characters, CI_COMMIT_BRANCH holds the branch name, CI_PIPELINE_ID is the unique pipeline identifier, CI_PROJECT_NAME is the project name, CI_REGISTRY_IMAGE is the path to the project's container registry, and CI_MERGE_REQUEST_IID is the merge request number (available in merge request pipelines). These predefined variables eliminate the need for custom scripts to extract Git information and ensure consistency across all jobs. Variables can also be referenced within other variable definitions using the $VARIABLE_NAME syntax, enabling composition like IMAGE_TAG set to $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA.
In production, variables are critical for managing environment-specific configuration and secrets. A deployment pipeline for logistics-tracker might use project-level variables like KUBE_CONFIG (the base64-encoded kubeconfig for the target cluster), DOCKER_REGISTRY_PASSWORD (credentials for pushing images), and SENTRY_DSN (the error tracking endpoint). These are marked as masked (so their values never appear in job logs) and optionally protected (so they are only available in pipelines running on protected branches like main, preventing feature branch pipelines from accessing production secrets). Environment-scoped variables take this further: you can set DATABASE_URL to different values for the staging and production environments, and GitLab automatically injects the correct value based on which environment the job is deploying to. This eliminates the need for environment-specific configuration files in the repository.
A critical gotcha is accidentally exposing secrets in job logs. Even if a variable is marked as masked, GitLab can only mask exact matches of the variable value in the log output. If a script transforms the variable (for example, base64-encoding it or splitting it into parts), the masked value will not match the transformed output and the secret will appear in the logs. Always audit your pipeline logs after adding new secret variables. Another common mistake is defining secrets directly in .gitlab-ci.yml instead of using the GitLab UI, which means the secrets are committed to the repository history and visible to anyone with read access. Never put actual credentials in your pipeline file; use variable references instead.
Code Example
# Define global variables available to all jobs
variables:
APP_NAME: logistics-tracker # Application name used across jobs
DOCKER_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Compose registry path with commit SHA
stages:
- build # Build stage
- deploy # Deploy stage
# Build job uses global and predefined variables
build-image:
stage: build # Assign to build stage
image: docker:24.0 # Docker image for building
variables:
DOCKER_BUILDKIT: "1" # Job-level variable enabling BuildKit
script:
- echo "Building $APP_NAME at commit $CI_COMMIT_SHORT_SHA" # Use global and predefined vars
- docker build -t $DOCKER_TAG . # Tag image with registry path and SHA
- docker push $DOCKER_TAG # Push to GitLab Container Registry
# Deploy job uses protected and environment-scoped variables
deploy-staging:
stage: deploy # Assign to deploy stage
script:
- echo "Deploying $APP_NAME to staging" # Reference the global variable
- kubectl set image deployment/$APP_NAME app=$DOCKER_TAG # Use composed variable
environment:
name: staging # GitLab injects staging-scoped variables
only:
- main # Only run on the main branch
# Variables set in GitLab UI (Settings > CI/CD > Variables):
# KUBE_CONFIG = <base64 kubeconfig> [masked, protected]
# SENTRY_DSN = https://[email protected]/1 [masked]
# DATABASE_URL = postgres://... [masked, scoped to staging]Interview Tip
A junior engineer typically defines variables inline in .gitlab-ci.yml without distinguishing between configuration and secrets. Demonstrate security awareness by explaining that secrets must be stored in the GitLab UI under Settings > CI/CD > Variables, where they are encrypted, can be masked to prevent log exposure, and can be protected so they are only available on protected branches. Discuss variable precedence (pipeline overrides project overrides group) and environment-scoped variables that inject different values for staging versus production deployments. Mention predefined variables like CI_COMMIT_SHA and CI_REGISTRY_IMAGE that you use regularly. This shows both security consciousness and practical pipeline experience.
◈ Architecture Diagram
┌───────────────────────────────────────────────┐ │ Variable Precedence Hierarchy │ ├───────────────────────────────────────────────┤ │ │ │ Priority (highest → lowest): │ │ │ │ 1. ┌─────────────────────────┐ ▶ Highest │ │ │ Pipeline-level │ │ │ │ (manual trigger / API) │ │ │ └────────────┬────────────┘ │ │ ▼ │ │ 2. ┌─────────────────────────┐ │ │ │ Job-level variables │ │ │ │ (in .gitlab-ci.yml) │ │ │ └────────────┬────────────┘ │ │ ▼ │ │ 3. ┌─────────────────────────┐ │ │ │ Project-level │ │ │ │ (Settings > CI/CD) │ │ │ └────────────┬────────────┘ │ │ ▼ │ │ 4. ┌─────────────────────────┐ │ │ │ Group-level │ │ │ │ (inherited from parent) │ │ │ └────────────┬────────────┘ │ │ ▼ │ │ 5. ┌─────────────────────────┐ ▶ Lowest │ │ │ Instance-level │ │ │ │ (GitLab admin) │ │ │ └─────────────────────────┘ │ │ │ └───────────────────────────────────────────────┘
💬 Comments
Quick Answer
GitLab Runners are lightweight agents that pick up and execute CI/CD jobs defined in .gitlab-ci.yml. They can be shared across all projects, assigned to a specific group, or dedicated to a single project. Runners support multiple executors including Docker, Shell, Kubernetes, and Docker Machine, determining the environment in which jobs run.
Detailed Answer
Think of GitLab Runners like delivery drivers for a food delivery platform. The platform (GitLab server) receives orders (pipeline jobs) from restaurants (repositories). Delivery drivers (Runners) are registered with the platform, check for new orders that match their delivery zone (tags), pick up an order, deliver it (execute the script), and report back whether the delivery was successful. Some drivers work for a single restaurant chain (project-specific Runners), some cover an entire neighborhood (group Runners), and some accept deliveries across the whole city (shared Runners).
A GitLab Runner is an application written in Go that you install on a machine (physical server, virtual machine, or container). After installation, you register the Runner with a GitLab instance by providing the instance URL and a registration token. During registration, you choose an executor type that determines how jobs are run: the Docker executor spins up a fresh container for each job using the image specified in .gitlab-ci.yml, the Shell executor runs commands directly on the Runner's host machine, the Kubernetes executor creates pods in a Kubernetes cluster for each job, and the Docker Machine executor auto-scales by provisioning cloud VMs on demand. You also assign tags during registration, which are labels that determine which jobs the Runner can pick up. A Runner tagged with docker and linux will only execute jobs that specify those tags.
Internally, the Runner continuously polls the GitLab server's API (by default every three seconds) asking for available jobs. When it receives a job, it downloads the job specification including the script commands, variables, and artifact configuration. For the Docker executor, it pulls the specified Docker image, creates a container, mounts the cloned repository into the container, and executes the before_script, script, and after_script commands inside it. The Runner streams log output back to the GitLab server in real time via the API, allowing developers to watch job execution live in the web interface. When the script finishes, the Runner reports the exit code (0 for success, non-zero for failure), uploads any artifacts, and destroys the container. The Runner then immediately polls for the next available job.
In production, Runner architecture is a critical infrastructure decision. A typical enterprise setup uses shared Runners on a Kubernetes cluster for standard workloads (building code, running tests, deploying to staging), with auto-scaling enabled to handle peak demand during business hours and scale down overnight. Project-specific Runners on dedicated hardware handle jobs that need access to specialized resources like GPUs for ML model training or network-attached storage for large data processing. Group-level Runners serve teams that need consistent build environments, like the mobile team needing macOS Runners for iOS builds. The Runner's concurrent setting controls how many jobs it processes simultaneously, and the limit setting on the Kubernetes executor caps the number of pods to prevent cluster resource exhaustion. Monitoring Runner health through GitLab's admin panel and Prometheus metrics is essential for keeping pipelines fast and reliable.
A critical gotcha is security with shared Runners. Since shared Runners execute jobs from any project on the instance, a malicious .gitlab-ci.yml in one project could attempt to extract secrets from the Runner's host machine or interfere with other jobs running concurrently. The Docker executor mitigates this through container isolation, but the Shell executor has no isolation at all, meaning any job can access the Runner host's filesystem, environment variables, and network. Never use the Shell executor for shared Runners in a multi-tenant environment. Another common pitfall is not tagging Runners, which causes all jobs to pile onto the first available Runner regardless of whether it has the required tools, leading to job failures and wasted time.
Code Example
# Install GitLab Runner on a Linux server
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash # Add the repository
sudo apt-get install gitlab-runner # Install the Runner package
# Register the Runner with the GitLab instance
sudo gitlab-runner register \
--url https://gitlab.example.com/ \
--registration-token PROJECT_REG_TOKEN \
--executor docker \
--docker-image alpine:latest \
--description "Docker Runner for backend team" \
--tag-list "docker,linux,backend" # Tags determine which jobs this Runner accepts
# Verify the Runner is registered and online
sudo gitlab-runner list # Show all registered Runners on this host
# Configure concurrency in /etc/gitlab-runner/config.toml
# concurrent = 4 # Run up to 4 jobs simultaneously
# [[runners]] # Runner configuration block
# name = "backend-runner" # Display name in GitLab UI
# executor = "docker" # Use Docker executor
# [runners.docker] # Docker-specific settings
# image = "alpine:latest" # Default image if job doesn't specify
# privileged = false # Disable privileged mode for security
# Reference Runner tags in .gitlab-ci.yml
build-backend:
stage: build # Assign to the build stage
tags:
- docker # Only Runners with the 'docker' tag can run this
- backend # Must also have the 'backend' tag
script:
- go build -o api-server . # Build the Go binaryInterview Tip
A junior engineer typically says 'Runners run jobs' without explaining executor types, tagging, or security implications. Differentiate yourself by explaining the three Runner scopes (shared, group, project) and when to use each. Discuss executor types: Docker for isolated, reproducible builds; Shell for simple tasks on trusted infrastructure; and Kubernetes for auto-scaling in containerized environments. Highlight the security concern with shared Runners and the Shell executor: any project can access the host filesystem, so Docker or Kubernetes executors are mandatory for multi-tenant environments. Mention practical configuration like the concurrent setting for parallel job execution and tag-based job routing. This shows you understand Runners as infrastructure, not just a black box that runs scripts.
◈ Architecture Diagram
┌───────────────────────────────────────────────────┐
│ GitLab Server │
│ ┌────────────────────────────────────────────┐ │
│ │ Job Queue │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Job A │ │ Job B │ │ Job C │ │ │
│ │ │docker │ │shell │ │docker │ │ │
│ │ │backend │ │deploy │ │frontend│ │ │
│ │ └───┬────┘ └───┬────┘ └───┬────┘ │ │
│ └──────┼──────────┼──────────┼───────────────┘ │
└─────────┼──────────┼──────────┼────────────────────┘
│ │ │
polls │ polls │ polls │
▼ ▼ ▼
┌──────────┐┌──────────┐┌──────────┐
│ Runner 1 ││ Runner 2 ││ Runner 3 │
│ tags: ││ tags: ││ tags: │
│ docker ││ shell ││ docker │
│ backend ││ deploy ││ frontend │
│ ││ ││ │
│ executor:││ executor:││ executor:│
│ Docker ││ Shell ││ Docker │
└──────────┘└──────────┘└──────────┘
│ picks A │ │ picks B │ │ picks C │💬 Comments
Quick Answer
Shared Runners are available to all projects in a GitLab instance and managed by admins. Specific (project) Runners are dedicated to one project for isolation or compliance. Group Runners are shared across all projects within a group, balancing resource sharing with organizational boundaries.
Detailed Answer
Think of GitLab Runners like different tiers of taxis in a city. Shared Runners are the public taxi fleet available to anyone hailing a cab on any street (any project in the instance). Group Runners are like a corporate car service that serves only employees of a specific company division (projects within a group). Specific Runners are personal chauffeurs assigned to one VIP client (a single project), guaranteeing availability and a clean vehicle every time.
GitLab Runners are the agents that actually execute CI/CD jobs. When a pipeline is created, jobs are placed in a queue and Runners poll the GitLab server for work matching their scope and tags. Shared Runners are registered at the instance level by a GitLab administrator and are available to every project across the entire GitLab installation. They use a fair-usage queue that distributes jobs evenly across projects so that one project with a hundred pipelines cannot starve another project of resources. Group Runners are registered at the group level and serve all projects within that group and its subgroups. They are ideal when a department or team wants dedicated compute capacity without giving every project its own Runner. Specific Runners are locked to a single project, meaning they will only pick up jobs from that project. They offer maximum isolation and are commonly used when a project requires special hardware (GPUs for ML training), has compliance requirements (HIPAA data that cannot be processed on shared infrastructure), or needs guaranteed capacity for time-sensitive deployments.
Internally, Runner registration involves generating a token at the desired scope (instance, group, or project) and using it with the gitlab-runner register command. Each Runner stores its configuration in a config.toml file that defines the executor type (shell, docker, docker+machine, kubernetes, custom), concurrency limits, and default settings. When a Runner polls for jobs, it sends its token and tags to the GitLab API. The coordinator matches available jobs to Runners based on three criteria: scope (is this Runner authorized for the project?), tags (does the job require tags that the Runner provides?), and protected status (if the Runner is protected, it only runs jobs from protected branches or tags). The fair-usage queue for shared Runners works by tracking the last time each project used the shared Runner fleet and prioritizing projects that have waited the longest.
In production, organizations typically deploy a layered Runner strategy. A fintech company running payment-processing-api might configure shared Runners with tags like docker and linux for general-purpose CI jobs across all teams. The data-engineering group registers group Runners on GPU-equipped machines tagged ml-training for their model training pipelines. The compliance-sensitive audit-trail-service gets its own specific Runner on a hardened VM in a private subnet, tagged secure and isolated, ensuring no other project's code ever executes on that machine. The Runner fleet is usually managed using Infrastructure as Code with Terraform, deploying Runner instances on AWS EC2 or GCP Compute Engine with autoscaling groups. The docker+machine executor can spin up ephemeral VMs for each job, ensuring clean environments and enabling burst capacity during peak hours.
A critical gotcha is the interaction between Runner tags and job tag requirements. If a job specifies tags but no Runner with matching tags is available, the job will be stuck in pending state indefinitely, with no clear error message in the pipeline view. Conversely, if you have shared Runners enabled and a job has no tags, it will run on any available shared Runner, which might be in a different region or have different capabilities than expected. Always audit your Runner registrations with gitlab-runner list and verify tag assignments. Another common mistake is not setting the lock_to_current_project option on specific Runners, which could allow an admin to reassign them to other projects accidentally.
Code Example
# Register a shared Runner (requires admin token)
# Run on the Runner host machine
sudo gitlab-runner register \
--non-interactive \
--url https://gitlab.acme.com/ \
--token glrt-SHARED_RUNNER_TOKEN \
--executor docker \
--docker-image alpine:3.19 \
--description "shared-docker-runner-01" \
--tag-list "docker,linux,shared" \
--run-untagged=true # Accept jobs without tags
# Register a group Runner for data-engineering group
sudo gitlab-runner register \
--non-interactive \
--url https://gitlab.acme.com/ \
--token glrt-GROUP_RUNNER_TOKEN \
--executor docker \
--docker-image python:3.12 \
--description "data-eng-gpu-runner" \
--tag-list "gpu,ml-training" \
--run-untagged=false # Only run tagged jobs
# Register a specific Runner locked to one project
sudo gitlab-runner register \
--non-interactive \
--url https://gitlab.acme.com/ \
--token glrt-PROJECT_RUNNER_TOKEN \
--executor docker \
--docker-image alpine:3.19 \
--description "audit-service-secure-runner" \
--tag-list "secure,isolated" \
--locked=true # Lock this Runner to the current project
# .gitlab-ci.yml using different Runner types
stages:
- build # General build stage
- train # ML training stage
- deploy # Secure deployment stage
# This job runs on shared Runners (no specific tags needed)
build-api:
stage: build # Assign to build stage
image: golang:1.22 # Use Go image
script:
- go build -o api-server ./cmd/ # Compile the application
tags:
- docker # Matches shared Runners tagged 'docker'
# This job targets group Runners with GPU
train-model:
stage: train # Assign to train stage
script:
- python train.py --epochs 100 # Run model training
tags:
- gpu # Matches group Runner with GPU
- ml-training # Further narrows to ML-specific Runners
# This job targets the project-specific secure Runner
deploy-audit-service:
stage: deploy # Assign to deploy stage
script:
- kubectl apply -f k8s/production/ # Deploy to production
tags:
- secure # Matches the locked project Runner
- isolated # Ensures isolation complianceInterview Tip
A junior engineer typically says 'Runners run jobs' without distinguishing scope or explaining why scope matters. Elevate your answer by describing the three-tier Runner model and providing concrete use cases for each: shared for cost efficiency across the organization, group for team-level resource pooling, and specific for compliance or hardware isolation. Mention the fair-usage queue for shared Runners and how tag matching works to route jobs. Discuss autoscaling with the docker+machine executor for handling variable load, and explain the locked Runner setting for preventing accidental reassignment of sensitive infrastructure.
💬 Comments
Quick Answer
GitLab environments track where code is deployed (staging, production) and display deployment history in the UI. Review apps are dynamic, per-merge-request environments that spin up automatically when an MR is opened and tear down when it is merged or closed, giving reviewers a live preview of changes.
Detailed Answer
Think of environments and review apps like a theater company's rehearsal process. The main stage (production) is where the final show runs. The rehearsal hall (staging) is where the full cast practices before opening night. Review apps are like individual practice rooms where each actor can rehearse their solo scenes independently. When an actor finishes rehearsing, their practice room is freed up for someone else. The director (reviewer) can walk into any practice room to watch a specific actor's performance before deciding if it is ready for the main stage.
GitLab environments are declared within job definitions using the environment keyword. When a job with an environment runs successfully, GitLab records a deployment entry linking the commit SHA, the pipeline, and the environment name. The Environments page in the GitLab UI shows all environments, their current deployment status, the last deployed commit, and links to the deployed URL. This gives teams full traceability of what code is running where. Environments also support manual actions like rollbacks: GitLab keeps a history of all deployments to an environment, and you can redeploy any previous version directly from the UI. The environment keyword supports two key properties: name (the environment identifier) and url (the deployed application URL, displayed as a clickable link in the MR).
Internally, when a job with an environment completes, GitLab creates a Deployment record in its database. This record references the environment, the project, the pipeline, the commit SHA, and the deployment status. GitLab maintains an ordered list of deployments per environment, enabling the rollback feature. For dynamic environments (like review apps), GitLab uses variable interpolation in the environment name. When you set name: review/$CI_COMMIT_REF_SLUG, each branch gets its own environment. The on_stop action links a deployment job to a cleanup job: when the environment is stopped (manually or via auto_stop_in), GitLab triggers the stop job, which typically runs terraform destroy or kubectl delete to tear down the infrastructure. GitLab tracks the environment lifecycle through states: available (deployed and accessible), stopping (cleanup in progress), and stopped (resources removed).
In production, a team building a customer-portal application would configure three environment tiers. Feature branches get review apps deployed to ephemeral Kubernetes namespaces like review-feature-oauth-login, giving designers and product managers a live URL to test changes before code review. The staging environment receives deployments from the main branch after all tests pass, serving as the integration testing ground. Production is deployed manually or through a scheduled pipeline with approval gates. The review app configuration uses the Kubernetes executor to create namespaces dynamically, deploys the application with Helm using branch-specific values, and exposes it via an Ingress with a wildcard DNS record like *.review.portal.acme.com. Auto-stop is configured to shut down review apps after 48 hours of inactivity to save cluster resources.
A common gotcha is forgetting to configure the on_stop action for dynamic environments. Without it, review environments accumulate indefinitely, consuming cluster resources and making the Environments page unmanageable. Every review app deployment job should have a corresponding stop job with action: stop and the same environment name. Another pitfall is using CI_COMMIT_REF_NAME instead of CI_COMMIT_REF_SLUG for environment names; the slug version is URL-safe and lowercased, while the raw branch name might contain characters that break DNS or Kubernetes naming conventions.
Code Example
# Define pipeline stages
stages:
- build # Build the application
- review # Deploy review apps for MRs
- staging # Deploy to staging from main
- cleanup # Tear down review apps
# Build the Docker image
build-portal:
stage: build # Assign to build stage
image: docker:24.0 # Use Docker for building
script:
- docker build -t registry.acme.com/customer-portal:$CI_COMMIT_SHA . # Build image
- docker push registry.acme.com/customer-portal:$CI_COMMIT_SHA # Push to registry
# Deploy a review app for each merge request
deploy-review:
stage: review # Assign to review stage
image: bitnami/kubectl:1.29 # Use kubectl image
script:
- kubectl create namespace review-$CI_COMMIT_REF_SLUG --dry-run=client -o yaml | kubectl apply -f - # Create namespace idempotently
- helm upgrade --install portal-$CI_COMMIT_REF_SLUG ./helm/customer-portal # Deploy with Helm
--namespace review-$CI_COMMIT_REF_SLUG # Target the branch namespace
--set image.tag=$CI_COMMIT_SHA # Use the built image
--set ingress.host=$CI_COMMIT_REF_SLUG.review.portal.acme.com # Set dynamic hostname
environment:
name: review/$CI_COMMIT_REF_SLUG # Dynamic environment per branch
url: https://$CI_COMMIT_REF_SLUG.review.portal.acme.com # Clickable link in MR
on_stop: stop-review # Link to the cleanup job
auto_stop_in: 48 hours # Auto-stop after 48 hours of inactivity
rules:
- if: $CI_MERGE_REQUEST_IID # Only run for merge requests
# Stop and clean up the review app
stop-review:
stage: cleanup # Assign to cleanup stage
image: bitnami/kubectl:1.29 # Use kubectl image
script:
- helm uninstall portal-$CI_COMMIT_REF_SLUG --namespace review-$CI_COMMIT_REF_SLUG # Remove Helm release
- kubectl delete namespace review-$CI_COMMIT_REF_SLUG # Delete the namespace
environment:
name: review/$CI_COMMIT_REF_SLUG # Must match the deploy environment name
action: stop # Mark this as the stop action
rules:
- if: $CI_MERGE_REQUEST_IID # Only run for merge requests
when: manual # Allow manual trigger
allow_failure: true # Do not block pipeline if not triggered
# Deploy to staging from main branch
deploy-staging:
stage: staging # Assign to staging stage
script:
- helm upgrade --install portal ./helm/customer-portal # Deploy to staging
--namespace staging # Target staging namespace
--set image.tag=$CI_COMMIT_SHA # Use the built image
environment:
name: staging # Static environment name
url: https://staging.portal.acme.com # Staging URL
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only deploy from mainInterview Tip
A junior engineer typically defines an environment with just a name and moves on. Demonstrate depth by explaining the full review app lifecycle: dynamic environment creation using CI_COMMIT_REF_SLUG, the on_stop action for cleanup, auto_stop_in for resource management, and how the environment URL appears as a clickable link in the merge request. Discuss the deployment history that environments provide, including rollback capabilities. Mention wildcard DNS configuration for review app hostnames and the importance of using CI_COMMIT_REF_SLUG instead of CI_COMMIT_REF_NAME for URL safety. This shows you understand the operational side, not just the YAML.
💬 Comments
Quick Answer
GitLab CI caching stores directories like dependency folders between pipeline runs to avoid re-downloading them each time. Caches use keys to determine when to reuse versus regenerate, and strategies include per-branch keys, file-hash keys with cache:key:files, and fallback keys to balance speed with freshness.
Detailed Answer
Think of GitLab CI caching like a workbench in a woodworking shop. Each morning (new pipeline), instead of going to the hardware store to buy fresh screws, nails, and sandpaper, you check your workbench drawer (cache) first. If you find what you need, you save the trip and start building immediately. If the project requirements changed (new lock file), you know the drawer contents are stale, so you make the trip, buy fresh supplies, and refill the drawer for tomorrow. The key to the drawer is labeled with the project plan version (cache key), so different plans get different drawers.
Caching in GitLab CI/CD is configured using the cache keyword within a job. The paths property specifies which directories to cache, commonly node_modules, .pip, vendor/bundle, or .m2/repository. The key property determines how the cache is identified and when it is considered valid. If two jobs have the same cache key, they share the same cache archive. The simplest strategy is a fixed key like default, which means every pipeline reuses the same cache. A better strategy is $CI_COMMIT_REF_SLUG, which creates a per-branch cache so that feature branches do not pollute each other's caches. The most sophisticated strategy uses cache:key:files, which generates the key from the hash of specified files (like package-lock.json or Gemfile.lock), ensuring the cache is invalidated exactly when dependencies change.
Internally, caching works differently from artifacts. While artifacts are stored on the GitLab server and passed between stages within a pipeline, caches are stored on the Runner's local filesystem or in a configured S3-compatible backend. When a job starts, the Runner checks if a cache archive exists for the specified key. If found, it downloads and extracts the archive into the working directory before running the script. After the job completes (regardless of success or failure), the Runner creates a new archive of the cached paths and uploads it. The policy keyword controls this behavior: pull means only download the cache (never upload), push means only upload (never download), and pull-push (the default) does both. Using pull-only on test jobs that consume but never change dependencies avoids unnecessary upload time. The fallback_keys feature allows specifying alternative cache keys to try if the primary key misses, enabling a graduated freshness strategy.
In production, a team working on logistics-dashboard with a Node.js frontend and Python backend would configure layered caching. The frontend jobs use cache:key:files pointing to package-lock.json, caching node_modules. When a developer updates dependencies (changing the lock file), the cache key changes automatically, forcing a fresh npm ci. Meanwhile, all other pipelines where the lock file has not changed hit the cache and skip the download entirely, saving 30-90 seconds per job. The backend jobs cache the pip download directory keyed to requirements.txt. For further optimization, the team splits the cache into a preparation job with policy: push that runs npm ci once, followed by multiple test jobs with policy: pull that only consume the cache. This prevents three test jobs from redundantly uploading the same cache archive.
A critical gotcha is the difference between cache and artifacts. Caches are best-effort and may not be available, so your job must work even if the cache is empty (always run npm ci, not npm install). Artifacts are guaranteed and used for passing build outputs between stages. Another common mistake is caching the wrong directory; for example, caching node_modules works but caching the npm cache directory (~/.npm) is often more reliable because npm ci deletes node_modules before installing. Also, be aware that caches are scoped to the Runner: if you have five Runners, a cache created on Runner 1 is not available on Runner 2 unless you use a distributed cache backend like S3.
Code Example
# Define stages
stages:
- install # Install dependencies once
- test # Run multiple test jobs
- build # Build the application
# Variables shared across all jobs
variables:
NPM_CONFIG_CACHE: $CI_PROJECT_DIR/.npm # Store npm cache inside project directory
PIP_CACHE_DIR: $CI_PROJECT_DIR/.pip # Store pip cache inside project directory
# Install dependencies and push to cache
install-deps:
stage: install # Assign to install stage
image: node:20-alpine # Use Node.js image
script:
- npm ci # Install exact versions from lock file
cache:
key:
files:
- package-lock.json # Key changes when lock file changes
paths:
- .npm # Cache the npm download cache
- node_modules # Cache installed packages
policy: push # Only upload cache, do not download
# Unit tests consume the cache (read-only)
unit-tests:
stage: test # Assign to test stage
image: node:20-alpine # Same Node.js image
script:
- npm run test:unit # Run unit tests
cache:
key:
files:
- package-lock.json # Same key to match install job
paths:
- .npm # Restore npm cache
- node_modules # Restore installed packages
policy: pull # Only download cache, never upload
fallback_keys:
- npm-cache-main # Fall back to main branch cache if key misses
# Lint job also uses the cache read-only
lint-check:
stage: test # Runs in parallel with unit-tests
image: node:20-alpine # Same Node.js image
script:
- npm run lint # Run ESLint checks
cache:
key:
files:
- package-lock.json # Same key to match install job
paths:
- .npm # Restore npm cache
- node_modules # Restore installed packages
policy: pull # Read-only cache access
# Python backend caching example
backend-tests:
stage: test # Assign to test stage
image: python:3.12-slim # Use Python image
script:
- pip install -r requirements.txt # Install Python dependencies
- pytest tests/ -v # Run pytest suite
cache:
key:
files:
- requirements.txt # Key based on Python dependencies
prefix: python # Prefix avoids collision with Node cache keys
paths:
- .pip # Cache pip downloads
policy: pull-push # Both download and upload cacheInterview Tip
A junior engineer typically sets a static cache key and caches node_modules without understanding the nuances. Stand out by explaining cache:key:files for automatic invalidation when lock files change, the policy keyword (pull, push, pull-push) for controlling cache flow, and fallback_keys for graceful degradation. Discuss the crucial difference between cache and artifacts: caches are best-effort performance optimizations stored on Runners, while artifacts are guaranteed data transfers stored on the GitLab server. Mention distributed cache backends for multi-Runner setups and why npm ci is safer than npm install even with a cache.
💬 Comments
Quick Answer
A DAG (Directed Acyclic Graph) pipeline uses the needs keyword to define direct job-to-job dependencies, allowing jobs to start as soon as their specific dependencies complete rather than waiting for an entire stage to finish. This can dramatically reduce pipeline duration by increasing parallelism.
Detailed Answer
Think of a DAG pipeline like a cooking recipe where dishes are prepared independently. In a traditional kitchen (stage-based), the chef finishes all appetizers before starting any main courses, even if the salad has nothing to do with the soup. In a DAG kitchen, the moment the salad ingredients are prepped, the salad assembly starts immediately while the soup is still simmering. Each dish only waits for its own ingredients, not for every dish in the previous course to be finished.
In GitLab's default execution model, jobs are organized into stages, and all jobs in a stage must complete before any job in the next stage begins. This provides a simple, predictable execution order but creates artificial bottlenecks. For example, if your test stage has a 10-minute integration test and a 1-minute lint check, the deploy stage waits for both to finish even if the deploy job only depends on the build output and lint result. The needs keyword breaks this stage barrier by letting you declare that a job depends on specific jobs rather than an entire stage. When you add needs: [build-frontend] to a job, that job starts as soon as build-frontend completes, regardless of other jobs still running in the build stage. The result is a Directed Acyclic Graph where jobs form a dependency tree rather than a flat sequence of stages.
Internally, GitLab's pipeline processor evaluates the needs declarations to build a dependency graph. When a job completes, the processor checks if any jobs that need it now have all their dependencies satisfied. If so, those jobs are immediately moved from created to pending state and queued for Runner execution. The stage keyword still matters for visual grouping in the pipeline view and as a fallback ordering mechanism, but the actual execution order is determined by the needs graph. GitLab also uses the needs relationship for artifact passing: by default, a job with needs only receives artifacts from the jobs it needs, not from all jobs in previous stages. You can override this with artifacts: true or artifacts: false on each needs entry. There is a configurable limit (default 50) on the number of needs entries per job, enforced to prevent overly complex dependency graphs.
In production, DAG pipelines provide the biggest benefit for monorepo or multi-component projects. Consider a repository for logistics-platform that contains a frontend (React), a backend API (Go), and a worker service (Python). In a stage-based pipeline, the test stage would wait for all three build jobs to finish before any tests start. With needs, the frontend-test job starts the moment frontend-build completes, even while backend-build is still compiling Go code. A real-world pipeline that took 25 minutes with strict stage ordering can drop to 15 minutes using DAG because independent components flow through the pipeline at their own pace. Teams at companies like GitLab itself use needs extensively in their monorepo pipelines to keep CI feedback loops short despite having hundreds of jobs.
A key gotcha is that using needs: [] (an empty array) means the job has no dependencies and starts immediately when the pipeline is created, even before any other job runs. This is useful for jobs like static analysis that do not need any build artifacts. However, forgetting to include a necessary dependency causes the job to run without the required artifacts, leading to confusing failures. Another pitfall is mixing needs with stage-based ordering: if you add needs to some jobs but not others in the same stage, the jobs without needs still follow stage-based ordering while the needs jobs run according to the DAG. This inconsistency can make pipeline behavior hard to predict.
Code Example
# Define stages for visual grouping in the pipeline view
stages:
- build # Build components
- test # Test components
- security # Security scanning
- deploy # Deployment stage
# Build the frontend application
build-frontend:
stage: build # Visual grouping in build stage
image: node:20-alpine # Use Node.js for frontend
script:
- npm ci # Install dependencies
- npm run build # Build the React application
artifacts:
paths:
- dist/ # Save built frontend assets
# Build the backend API
build-backend:
stage: build # Also in build stage (parallel with frontend)
image: golang:1.22 # Use Go for backend
script:
- go build -o api-server ./cmd/api # Compile Go binary
artifacts:
paths:
- api-server # Save compiled binary
# Frontend tests start as soon as frontend build completes
# Does NOT wait for build-backend to finish
test-frontend:
stage: test # Visual grouping in test stage
image: node:20-alpine # Use Node.js
needs:
- build-frontend # Only depends on frontend build
script:
- npm ci # Install dependencies
- npm run test:unit # Run frontend unit tests
# Backend tests start as soon as backend build completes
# Does NOT wait for build-frontend to finish
test-backend:
stage: test # Visual grouping in test stage
image: golang:1.22 # Use Go
needs:
- build-backend # Only depends on backend build
script:
- go test ./... -v # Run Go tests
# SAST scan has no dependencies, starts immediately
sast-scan:
stage: security # Visual grouping in security stage
needs: [] # No dependencies, runs at pipeline start
image: semgrep/semgrep:latest # Use Semgrep for SAST
script:
- semgrep scan --config auto . # Run static analysis
allow_failure: true # Do not block pipeline on scan findings
# Deploy starts when BOTH test jobs and SAST complete
deploy-staging:
stage: deploy # Assign to deploy stage
needs:
- test-frontend # Wait for frontend tests
- test-backend # Wait for backend tests
- sast-scan # Wait for security scan
script:
- kubectl apply -f k8s/staging/ # Deploy to staging
environment:
name: staging # Track staging deployments
url: https://staging.logistics.acme.com # Staging URLInterview Tip
A junior engineer typically describes pipelines as purely stage-based and does not mention DAG capabilities. Differentiate yourself by explaining that the needs keyword creates job-to-job dependencies that override stage ordering, enabling faster pipelines through increased parallelism. Use a concrete example: in a multi-component project, frontend tests can start as soon as the frontend builds, without waiting for backend builds. Mention needs: [] for zero-dependency jobs, the artifact scoping behavior (jobs with needs only receive artifacts from their dependencies), and the configurable limit on needs entries. This shows you can optimize pipeline performance, not just make pipelines work.
💬 Comments
Quick Answer
GitLab Container Registry is a built-in Docker registry integrated into each project. Images are stored at registry.gitlab.com/group/project and can be pushed during CI/CD using the predefined CI_REGISTRY variables. It supports image expiration policies, vulnerability scanning, and seamless authentication via CI job tokens.
Detailed Answer
Think of GitLab Container Registry like a company warehouse attached directly to the factory floor. Instead of shipping your products (Docker images) to a third-party storage facility (Docker Hub, ECR), you store them right next to the assembly line (CI/CD pipeline). Workers (Runners) have automatic badge access (CI_JOB_TOKEN) to the warehouse, and every department (project) has its own labeled shelving section (image repository).
GitLab Container Registry is an integrated Docker registry that comes bundled with GitLab. Every project has its own registry namespace at registry.gitlab.com/<namespace>/<project>. Images can have multiple tags and follow the standard OCI image specification. The registry is fully integrated with GitLab's authentication system: users authenticate with their personal access tokens or deploy tokens, and CI/CD jobs authenticate automatically using the CI_JOB_TOKEN predefined variable. The registry supports both Docker manifest v2 and OCI image manifests, meaning it works with Docker, Podman, Buildah, and any OCI-compliant tool. Each project's registry is accessible from the project sidebar under Packages & Registries > Container Registry, where you can browse images, view tags, see image sizes, and delete tags manually.
Internally, GitLab Container Registry runs as a separate service (based on the Docker Distribution project) that shares authentication with the GitLab Rails application. When a docker push command is executed, the Docker client first contacts GitLab for an authentication token, then pushes image layers to the registry storage backend. GitLab supports local filesystem, S3, GCS, and Azure Blob Storage as storage backends for the registry. Each image tag is a manifest pointing to a set of layers (blobs). The garbage collection process removes unreferenced blobs to reclaim storage space. For CI/CD integration, GitLab provides several predefined variables: CI_REGISTRY (the registry URL), CI_REGISTRY_IMAGE (the project's image path), CI_REGISTRY_USER (set to gitlab-ci-token), and CI_REGISTRY_PASSWORD (the job token). These variables enable seamless authentication without hardcoding credentials.
In production, a team building notification-service would configure their pipeline to build and push images on every commit to main and on merge request branches. The build job uses Docker-in-Docker (dind) or Kaniko (a rootless alternative) to build the image, tags it with the commit SHA for immutability and also tags main builds as latest. The deployment job pulls the image from the registry using the commit SHA tag, ensuring exactly the built version is deployed. Teams configure cleanup policies to automatically delete tags older than 90 days or tags matching patterns like branch-* that were created for MR builds. For multi-architecture support, the pipeline uses docker buildx to create manifest lists containing both AMD64 and ARM64 images under a single tag, enabling deployment to mixed-architecture clusters.
A critical gotcha is running out of storage because old image tags are never cleaned up. Without a cleanup policy, every pipeline push adds image layers that accumulate indefinitely. Configure the Container Registry cleanup policy under Settings > Packages & Registries > Clean up image tags, setting rules like keep the 5 most recent tags and delete tags older than 14 days matching the regex feature-.*. Another common mistake is using Docker-in-Docker with the docker executor without enabling TLS or using the --privileged flag, which causes build failures. Using Kaniko avoids this issue entirely since it builds images in userspace without requiring Docker daemon access.
Code Example
# Define pipeline stages
stages:
- build # Build and push Docker image
- test # Test the built image
- deploy # Deploy to Kubernetes
# Build and push Docker image using Docker-in-Docker
build-image:
stage: build # Assign to build stage
image: docker:24.0 # Use Docker CLI image
services:
- docker:24.0-dind # Run Docker daemon as a service
variables:
DOCKER_TLS_CERTDIR: "/certs" # Enable TLS for DinD
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY # Authenticate to GitLab registry
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . # Build with commit SHA tag
- docker build -t $CI_REGISTRY_IMAGE:latest . # Also tag as latest
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Push SHA-tagged image
- docker push $CI_REGISTRY_IMAGE:latest # Push latest tag
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only build on main branch
# Alternative: Build with Kaniko (no Docker daemon needed)
build-kaniko:
stage: build # Assign to build stage
image:
name: gcr.io/kaniko-project/executor:v1.22.0-debug # Use Kaniko executor
entrypoint: [""] # Override default entrypoint
script:
- >- # Build and push in one command
/kaniko/executor
--context $CI_PROJECT_DIR
--dockerfile $CI_PROJECT_DIR/Dockerfile
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
--cache=true
--cache-repo=$CI_REGISTRY_IMAGE/cache
rules:
- if: $CI_MERGE_REQUEST_IID # Build for merge requests using Kaniko
# Test the built image
smoke-test:
stage: test # Assign to test stage
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Use the image we just built
script:
- notification-service --version # Verify the binary runs
- notification-service --healthcheck # Verify health endpoint
# Deploy to Kubernetes using the registry image
deploy-production:
stage: deploy # Assign to deploy stage
image: bitnami/kubectl:1.29 # Use kubectl
script:
- kubectl set image deployment/notification-service # Update deployment image
app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Point to the exact build
--namespace production # Target production namespace
environment:
name: production # Track production deployments
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only deploy from main
when: manual # Require manual trigger for productionInterview Tip
A junior engineer typically describes pushing to Docker Hub and misses that GitLab has a built-in registry with automatic CI authentication. Show expertise by explaining the predefined CI_REGISTRY variables that eliminate credential management, the difference between Docker-in-Docker and Kaniko for building images in CI, and the container cleanup policies for managing storage. Mention tagging strategies (commit SHA for immutability, branch slug for review apps) and the garbage collection process for reclaiming space from deleted tags. Discuss multi-architecture builds with buildx for mixed ARM/AMD clusters.
💬 Comments
Quick Answer
GitLab approval rules define how many approvals an MR needs and from whom before it can be merged. CODEOWNERS is a file that maps file paths to responsible teams or individuals, automatically requiring their approval when their owned files are modified. Together they enforce code review governance without manual oversight.
Detailed Answer
Think of merge request approvals like a document signing process in a law firm. A junior associate (developer) drafts a contract (code change) and submits it for review. The firm's policy (approval rules) requires signatures from a senior partner (tech lead approval) and the department head for the relevant practice area (CODEOWNERS-based approval). The contract cannot be filed (merged) until all required signatures are collected. The CODEOWNERS file is like the firm's org chart that maps each practice area to the responsible partner.
GitLab supports multiple approval rules on a project. Each rule specifies the number of required approvals and the eligible approvers, which can be specific users, GitLab groups, or a combination. Rules can be configured at the project level (Settings > Merge Requests > Approval rules) or defined in the merge request itself. GitLab Premium and Ultimate tiers support additional features: preventing the MR author from approving their own changes, preventing users who pushed commits from approving, requiring re-approval when new commits are pushed, and approval rules based on which files were changed. The CODEOWNERS file (placed at the root of the repository, in .gitlab/, or in docs/) uses a gitignore-like syntax to map file patterns to groups or users. When an MR modifies files covered by CODEOWNERS entries, GitLab automatically requires approval from the designated owners.
Internally, when a merge request is created or updated, GitLab evaluates all configured approval rules. For CODEOWNERS-based rules, GitLab compares the list of changed files in the MR against the patterns in the CODEOWNERS file. Each matched pattern generates an approval requirement. GitLab tracks approvals in a dedicated database table, recording which user approved, when they approved, and which commit SHA they approved. When new commits are pushed to the MR branch and the project has the require_reapproval setting enabled, all existing approvals are reset, forcing reviewers to re-examine the changes. The approval status is also surfaced in the merge request API, allowing external tools and chatbots to query whether an MR is ready to merge. Protected branches can be configured to require approval rules to be satisfied before merging, making it impossible to bypass the review process even for maintainers.
In production, an e-commerce company managing checkout-service would configure a layered approval strategy. The base rule requires two approvals from anyone in the @checkout-team group. A CODEOWNERS rule requires approval from @security-team when files in src/payment/ or src/encryption/ are modified. Another CODEOWNERS rule requires @platform-team approval for changes to the Dockerfile, .gitlab-ci.yml, or Kubernetes manifests in k8s/. An additional rule requires @database-team approval when migration files in db/migrations/ are changed. This ensures that security-sensitive payment code is always reviewed by security engineers, infrastructure changes are reviewed by the platform team, and database schema changes are reviewed by DBAs, all without manual assignment. The approval requirements appear prominently in the MR widget, showing reviewers which rules they can satisfy.
A key gotcha is the order of pattern matching in CODEOWNERS. Like .gitignore, later entries override earlier ones. If you have a broad rule like * @default-team followed by *.go @backend-team, Go files will be owned by @backend-team only, not @default-team. Another common issue is using usernames instead of group names in CODEOWNERS; if an individual leaves the company, their CODEOWNERS entries become orphaned and no one can satisfy the approval requirement, blocking all MRs that touch those files. Always use group-based ownership for resilience. Also note that CODEOWNERS approval is a GitLab Premium feature; on the Free tier, CODEOWNERS is informational only.
Code Example
# CODEOWNERS file - placed at repository root or .gitlab/CODEOWNERS
# Default owners for everything in the repository
* @checkout-team # Fallback: checkout team owns all unmatched files
# Backend Go code owned by backend team
*.go @backend-team # All Go source files require backend review
# Payment-related code requires security team review
src/payment/ @security-team @backend-team # Both teams must approve payments code
src/encryption/ @security-team # Security team owns encryption modules
# Infrastructure files require platform team review
Dockerfile @platform-team # Platform team reviews container definitions
.gitlab-ci.yml @platform-team # Platform team reviews pipeline changes
k8s/ @platform-team # Platform team reviews Kubernetes manifests
helm/ @platform-team # Platform team reviews Helm charts
# Database migrations require DBA approval
db/migrations/ @database-team # DBAs review all schema changes
# Frontend assets owned by frontend team
src/frontend/ @frontend-team # Frontend team owns UI components
*.css @frontend-team # Frontend team reviews stylesheets
*.tsx @frontend-team # Frontend team reviews React components
# Documentation can be approved by any team lead
docs/ @tech-leads # Tech leads review documentation updates
# ---------------------------------------------------
# API call to configure approval rules programmatically
# ---------------------------------------------------
# Create a project-level approval rule requiring 2 approvals
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"name": "Backend Review",
"approvals_required": 2,
"group_ids": [42],
"rule_type": "regular"
}' \
"https://gitlab.acme.com/api/v4/projects/15/approval_rules" # Create rule for project 15
# Approve a specific merge request via API
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
"https://gitlab.acme.com/api/v4/projects/15/merge_requests/87/approve" # Approve MR 87
# List current approval status of a merge request
curl --header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
"https://gitlab.acme.com/api/v4/projects/15/merge_requests/87/approval_state" # Check approval stateInterview Tip
A junior engineer typically says 'we require two approvals' without explaining how different rules can target different reviewers based on file paths. Demonstrate maturity by describing the CODEOWNERS file syntax, how patterns map to groups, and how this creates automatic routing of reviews to domain experts. Mention the interaction between CODEOWNERS and protected branches, the re-approval requirement when new commits are pushed, and the gotcha of using individual usernames instead of groups. Discuss a real approval topology you have used, showing that you understand the governance model, not just that approvals exist.
💬 Comments
Quick Answer
Protected branches restrict who can push, merge, and force-push to critical branches like main and release/*. Protected tags prevent unauthorized users from creating or updating release tags. Both features use role-based access levels (Maintainers, Developers, No one) to enforce governance at the Git level.
Detailed Answer
Think of protected branches like the vault in a bank. Anyone can fill out a deposit slip at the counter (push to feature branches), but only authorized managers (Maintainers) can open the vault (push to main). Even managers must follow the dual-control policy (merge request with approvals) before moving funds (code) into the vault. The vault has additional safeguards: no one can erase transaction history (force push is disabled), and the safety deposit boxes (protected tags) require manager authorization to open or create.
Protected branches in GitLab restrict three operations on specified branches: who can push directly, who can merge via merge requests, and who can force push. By default, when you create a GitLab project, the main branch is automatically protected with Maintainers allowed to push and merge. You can configure additional protected branches using exact names (release/v2.0) or wildcard patterns (release/*). Each protected branch rule specifies the allowed push access level (No one, Developers + Maintainers, or Maintainers only) and the allowed merge access level (same options). GitLab Premium adds the ability to specify individual users or groups instead of role-based levels. Force push is disabled on protected branches by default, preventing history rewrites that could lose commits or break other developers' branches.
Internally, when a git push is received by GitLab, the pre-receive hook checks whether the target ref (branch or tag) is protected. If it is, GitLab evaluates the pusher's role in the project against the allowed push access level. If the user's role is below the required level, the push is rejected with a clear error message. For merge operations, when the merge button is clicked in a merge request, GitLab checks the user's role against the allowed merge access level for the target branch. Protected branches also interact with CI/CD: only jobs running on protected branches can access protected CI/CD variables and use protected Runners. This creates a security boundary where production secrets are only available during deployments triggered from the main or release branches. Protected tags follow a similar model but control who can create tags matching specified patterns.
In production, a financial services company building trading-engine would configure a comprehensive branch protection strategy. The main branch allows no one to push directly (all changes must go through MRs) and requires Maintainers to merge, with additional approval rules requiring two backend team approvals. Release branches matching release/* are protected similarly, ensuring only release managers can merge hotfixes. The develop branch allows Developers to push for daily integration work but requires Maintainers to merge to main. Protected tags matching v* ensure only release managers can create version tags that trigger production deployments. Protected CI/CD variables like PRODUCTION_DB_PASSWORD and DEPLOY_KEY are scoped to protected branches, ensuring they are never exposed in feature branch pipelines. This layered protection prevents junior developers from accidentally deploying to production or accessing production credentials.
A common gotcha is the interaction between protected branches and CI/CD variables. If you mark a variable as protected, it is only available to jobs running on protected branches or tags. Developers working on feature branches will get empty values for these variables, causing confusing failures. Always use non-protected variables for non-sensitive values needed in feature branch pipelines, and document which variables are protected. Another mistake is protecting branches with overly broad wildcards like * which protects every branch, preventing developers from pushing to any branch at all. Be specific with patterns like release/* or hotfix/*.
Code Example
# Configure protected branches via GitLab API
# Protect the main branch: no direct push, Maintainers can merge
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"name": "main",
"push_access_level": 0,
"merge_access_level": 40,
"allow_force_push": false,
"code_owner_approval_required": true
}' \
"https://gitlab.acme.com/api/v4/projects/15/protected_branches" # Protect main branch
# Protect all release branches with wildcard pattern
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"name": "release/*",
"push_access_level": 40,
"merge_access_level": 40,
"allow_force_push": false
}' \
"https://gitlab.acme.com/api/v4/projects/15/protected_branches" # Protect release branches
# Protect version tags so only Maintainers can create them
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"name": "v*",
"create_access_level": 40
}' \
"https://gitlab.acme.com/api/v4/projects/15/protected_tags" # Protect version tags
# .gitlab-ci.yml demonstrating protected variable usage
stages:
- build # Build stage for all branches
- deploy # Deploy stage for protected branches only
# This job runs on all branches
build-app:
stage: build # Assign to build stage
script:
- echo "Building from branch $CI_COMMIT_BRANCH" # Print current branch
- docker build -t app:$CI_COMMIT_SHA . # Build Docker image
# This job only runs on protected branches and uses protected variables
deploy-production:
stage: deploy # Assign to deploy stage
script:
- echo "Deploying with protected credentials" # Log deployment start
- kubectl --token=$KUBE_DEPLOY_TOKEN apply -f k8s/ # Use protected variable for auth
variables:
KUBE_DEPLOY_TOKEN: $PRODUCTION_KUBE_TOKEN # Protected variable, only on protected branches
environment:
name: production # Track production deployments
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only run on main branch
when: manual # Require manual trigger
# List all protected branches for a project
curl --header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
"https://gitlab.acme.com/api/v4/projects/15/protected_branches" # List protected branchesInterview Tip
A junior engineer typically knows that main is protected but cannot explain the configurable access levels or the interaction with CI/CD variables. Demonstrate expertise by describing the three controlled operations (push, merge, force push), the role-based access levels (No one, Developers, Maintainers), and wildcard patterns for protecting branch families like release/*. Crucially, explain the security boundary that protected branches create for CI/CD: protected variables and protected Runners are only available on protected branches, keeping production secrets isolated from feature branch pipelines. This shows you understand branch protection as a security architecture, not just a UI checkbox.
💬 Comments
Quick Answer
GitLab Pages hosts static websites directly from a GitLab repository. You configure a job named pages that produces built files in a public/ directory as an artifact. When the job succeeds on the default branch, GitLab automatically publishes the site at namespace.gitlab.io/project-name with optional custom domain support.
Detailed Answer
Think of GitLab Pages like a display window at a bookstore. You write your book (static site content), hand it to the store clerk (the pages job), and they place it in the display window (GitLab Pages hosting). The display window has a standard address on the street (namespace.gitlab.io/project), but you can also put your own custom sign (custom domain) on the window. The content in the window updates automatically every time you submit a revised manuscript (push to the default branch).
GitLab Pages is a free static site hosting service built into GitLab. It requires a job named exactly pages in your .gitlab-ci.yml that produces an artifact with a directory named exactly public. When this job succeeds on the default branch (usually main), GitLab takes the contents of the public directory and serves them as a static website. The site is available at https://<namespace>.gitlab.io/<project-name> for project-level Pages, or https://<namespace>.gitlab.io for a special project named <namespace>.gitlab.io. GitLab Pages supports any static site generator (Hugo, Jekyll, Gatsby, Next.js static export, MkDocs, Sphinx) or plain HTML/CSS/JS. Custom domains and SSL certificates are supported through the project settings, and GitLab can automatically provision Let's Encrypt certificates for custom domains.
Internally, when the pages job completes, the GitLab CI/CD system detects that the job name is pages and that it has a public directory in its artifacts. It triggers the Pages deployment process, which extracts the public directory from the artifact archive and stores the files in the Pages storage backend (local filesystem or object storage). The GitLab Pages daemon serves these files through a dedicated NGINX or custom HTTP server that handles routing, custom domains, and TLS termination. For custom domains, you add a CNAME or A record in your DNS pointing to the GitLab Pages IP, and configure the domain in the project settings (Settings > Pages > New Domain). GitLab validates domain ownership through a DNS TXT record and can automatically provision and renew Let's Encrypt certificates. The Pages daemon routes incoming requests to the correct project's files based on the domain and path.
In production, teams use GitLab Pages for various purposes beyond simple websites. An API platform team hosting developer-docs publishes their API documentation generated by Swagger UI, with every commit to main triggering a rebuild. A design system team hosts their Storybook component library as a Pages site, giving designers and frontend developers a live reference. An SRE team publishes their runbooks generated from Markdown by MkDocs, ensuring operational documentation is always up to date. For more complex setups, teams configure Pages deployment to run only when documentation files change using the rules keyword with changes detection on docs/ or content/ directories. Access control for Pages sites is available on GitLab Premium, allowing you to restrict access to project members only rather than making the site public.
A common gotcha is the artifact directory name: it must be exactly public, not build, dist, or output. If your static site generator outputs to a different directory (like Hugo's public or Gatsby's public), you are fine, but if it outputs to dist (like Vite or Angular), you need to either configure the generator to output to public or add a cp -r dist public step. Another mistake is naming the job anything other than pages, such as deploy-pages or publish-site, which will not trigger the Pages deployment process. The job must be named pages exactly. Also, Pages deployment only triggers on the default branch by default; if you want to deploy from a different branch, you need to configure that in the project Pages settings.
Code Example
# Hugo static site deployment to GitLab Pages
stages:
- build # Build the static site
- deploy # Deploy stage (pages job uses this implicitly)
# Build the Hugo site
build-docs:
stage: build # Assign to build stage
image: klakegg/hugo:0.115.4-ext # Use Hugo extended image
script:
- hugo --minify # Build the site with minification
artifacts:
paths:
- public # Hugo outputs to public/ by default
expire_in: 1 hour # Keep build artifact briefly
# The job MUST be named 'pages' for GitLab Pages deployment
pages:
stage: deploy # Assign to deploy stage
needs:
- build-docs # Depends on the build job
script:
- echo "Deploying to GitLab Pages" # Placeholder script (artifacts already contain public/)
artifacts:
paths:
- public # MUST be exactly 'public' for Pages to work
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Only deploy from default branch
# ---------------------------------------------------
# Alternative: React/Vite app (outputs to dist/)
# ---------------------------------------------------
pages:
stage: deploy # Assign to deploy stage
image: node:20-alpine # Use Node.js image
script:
- npm ci # Install dependencies
- npm run build # Build React app (outputs to dist/)
- mv dist public # Rename dist to public (REQUIRED for Pages)
artifacts:
paths:
- public # Must be 'public' for Pages deployment
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Deploy on default branch only
# ---------------------------------------------------
# Configure custom domain via API
# ---------------------------------------------------
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--header "Content-Type: application/json" \
--data '{
"domain": "docs.acme.com",
"auto_ssl_enabled": true
}' \
"https://gitlab.acme.com/api/v4/projects/15/pages/domains" # Add custom domain with auto SSLInterview Tip
A junior engineer typically forgets the two strict requirements: the job must be named pages and the artifact directory must be named public. Lead with these constraints and explain why they exist (GitLab uses convention over configuration to trigger the Pages deployment). Discuss the custom domain workflow including DNS configuration and automatic Let's Encrypt certificate provisioning. Mention practical use cases beyond simple websites: API documentation with Swagger, component libraries with Storybook, and operational runbooks with MkDocs. Access control for Pages sites on Premium tier is a strong point to mention for enterprise contexts.
💬 Comments
Quick Answer
GitLab CI/CD variables can be defined at the instance, group, project, pipeline, job, and .gitlab-ci.yml levels. When the same variable is defined at multiple levels, a strict precedence order applies: job-level YAML variables override pipeline trigger variables, which override project variables, which override group variables, which override instance variables.
Detailed Answer
Think of variable precedence like a layered dress code at a company. The CEO sets company-wide rules (instance variables): business casual. The VP of Engineering overrides for the tech department (group variables): jeans are fine. The project manager overrides for a specific team (project variables): t-shirts allowed during crunch. A developer overrides for their task (pipeline/job variables): pajamas during the all-night deploy. The most specific rule wins, but each layer can only override the ones above it.
GitLab CI/CD variables can be defined at six different scopes, each with its own precedence level. From lowest to highest priority: instance-level variables (set by admins, available to all projects), group-level variables (set on a group, available to all projects in the group), project-level variables (set in project Settings > CI/CD > Variables), variables defined in the variables keyword in .gitlab-ci.yml at the global level, variables defined in the variables keyword within a specific job in .gitlab-ci.yml, and variables passed when triggering a pipeline manually or via the API. There are also predefined variables that GitLab automatically sets for every pipeline (like CI_COMMIT_SHA, CI_PIPELINE_ID, CI_JOB_TOKEN), which cannot be overridden. Variables can be marked as protected (only available on protected branches), masked (hidden in job logs), or both.
Internally, when a job is about to execute, the GitLab Runner receives a set of variables from the GitLab server. The server compiles this set by walking through all variable scopes in precedence order, with higher-priority values overriding lower-priority ones. The Runner then injects these variables as environment variables into the job's execution environment. For file-type variables, GitLab writes the variable value to a temporary file and sets the variable to the file path, which is useful for injecting certificates, kubeconfig files, or service account keys. Variable expansion (referencing one variable inside another using $VARIABLE syntax) happens at the Runner level, with GitLab expanding variables in the YAML file during pipeline creation and the Runner expanding them during job execution. This two-phase expansion can cause subtle issues when a variable references another variable defined at a different scope.
In production, a company with multiple environments for inventory-api would leverage variable scoping strategically. Instance-level variables hold organization-wide settings like COMPANY_DOCKER_REGISTRY: registry.acme.com. Group-level variables for the logistics group define SENTRY_DSN and DATADOG_API_KEY shared across all logistics services. Project-level variables store project-specific values: DATABASE_NAME: inventory_db, with protected and masked PRODUCTION_DB_PASSWORD. The .gitlab-ci.yml file defines environment-specific variables within jobs: the staging deploy job sets APP_ENV: staging while the production deploy job sets APP_ENV: production. When a developer triggers a pipeline manually and overrides APP_ENV to debug, the trigger variable takes highest priority and overrides the job-level YAML variable. This layered approach eliminates hardcoded values, centralizes shared configuration, and keeps secrets out of the repository.
A critical gotcha is the masked variable limitation: GitLab can only mask variables whose values are at least 8 characters long and consist of characters from the Base64 alphabet. Short values like true, 3306, or dev cannot be masked, and attempting to mask them will either fail silently or warn in the UI. Another common mistake is expecting group variables to be inherited by subgroups without understanding the full inheritance chain. Variables flow from parent groups to subgroups to projects, but a project-level variable with the same name overrides the group-level one. The most dangerous pitfall is accidentally exposing protected variables by creating a branch that matches a protected branch pattern; audit your protected branch patterns regularly to ensure they only match intended branches.
Code Example
# .gitlab-ci.yml demonstrating variable precedence
# Global variables (lower priority than job-level variables)
variables:
APP_ENV: development # Default environment is development
LOG_LEVEL: info # Default log level
DOCKER_REGISTRY: $CI_REGISTRY # Reference predefined variable
stages:
- build # Build stage
- test # Test stage
- deploy # Deploy stage
# Job-level variables OVERRIDE global variables
build-app:
stage: build # Assign to build stage
variables:
LOG_LEVEL: debug # Overrides global LOG_LEVEL for this job only
script:
- echo "LOG_LEVEL is $LOG_LEVEL" # Prints 'debug', not 'info'
- echo "APP_ENV is $APP_ENV" # Prints 'development' (inherited from global)
- docker build -t $DOCKER_REGISTRY/inventory-api:$CI_COMMIT_SHA . # Build image
# Test job uses global variables as-is
unit-tests:
stage: test # Assign to test stage
variables:
DATABASE_URL: "postgres://test:test@postgres:5432/test_db" # Test database URL
services:
- postgres:16 # Spin up PostgreSQL for tests
script:
- echo "APP_ENV is $APP_ENV" # Prints 'development'
- echo "LOG_LEVEL is $LOG_LEVEL" # Prints 'info' (global level)
- pytest tests/ -v # Run test suite
# Deploy to staging with environment-specific variables
deploy-staging:
stage: deploy # Assign to deploy stage
variables:
APP_ENV: staging # Override global variable for staging
REPLICAS: "3" # Staging replica count
script:
- echo "Deploying $APP_ENV with LOG_LEVEL=$LOG_LEVEL" # staging, info
- kubectl set env deployment/inventory-api APP_ENV=$APP_ENV # Set env in k8s
environment:
name: staging # Track staging deployments
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only on main branch
# Deploy to production uses project-level protected variables
deploy-production:
stage: deploy # Assign to deploy stage
variables:
APP_ENV: production # Override for production
REPLICAS: "5" # Production replica count
script:
- echo "Using protected var DB_PASSWORD (masked in logs)" # Protected variable
- kubectl set env deployment/inventory-api DB_PASSWORD=$PRODUCTION_DB_PASSWORD # From project settings
environment:
name: production # Track production deployments
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only on main
when: manual # Manual trigger required
# ---------------------------------------------------
# Variable precedence order (highest to lowest):
# 1. Pipeline trigger variables (API/manual pipeline)
# 2. Job-level variables in .gitlab-ci.yml
# 3. Global variables in .gitlab-ci.yml
# 4. Project-level variables (Settings > CI/CD)
# 5. Group-level variables (Group > Settings > CI/CD)
# 6. Instance-level variables (Admin > CI/CD)
# ---------------------------------------------------
# Trigger a pipeline with variable override via API
curl --request POST \
--header "PRIVATE-TOKEN: glpat-xxxxxxxxxxxx" \
--form "ref=main" \
--form "variables[APP_ENV]=debug" \
--form "variables[LOG_LEVEL]=trace" \
"https://gitlab.acme.com/api/v4/projects/15/pipeline" # Trigger with overridesInterview Tip
A junior engineer typically puts all variables in the .gitlab-ci.yml file or project settings without understanding the precedence hierarchy. Show expertise by listing the six variable scopes from lowest to highest priority and explaining why each level exists. Discuss the protected and masked flags: protected limits variable availability to protected branches (keeping production secrets out of feature branch pipelines), and masked hides values in job logs (with the caveat that values must be at least 8 Base64 characters). Mention file-type variables for injecting certificates or kubeconfig files, and warn about the two-phase variable expansion that can cause unexpected behavior.
💬 Comments
Context
Meridian Financial Technologies, a mid-size fintech company with 120 developers, had been relying on a heavily customized Jenkins infrastructure with over 300 Groovy-based pipeline scripts to build and deploy their trading platform across multiple environments.
Problem
Meridian's Jenkins infrastructure had become a massive operational burden. The team maintained 14 Jenkins controller nodes and over 50 permanent build agents, requiring two full-time engineers just for CI/CD infrastructure upkeep. Jenkins plugins frequently conflicted during upgrades, causing unexpected build failures that could take hours to diagnose. The Groovy-based shared libraries had grown to over 15,000 lines of poorly documented code that only two senior engineers understood. Credential management was scattered across Jenkins credential stores, environment variables injected at the node level, and hardcoded values in scripts. Developers had no visibility into pipeline logic without SSH access to the Jenkins controllers, creating a knowledge silo. Build queue times averaged 25 minutes during peak hours because agent provisioning was manual. The lack of native integration between Jenkins and their GitLab repository meant merge request status checks were unreliable, often showing stale results. Audit trails for deployments were incomplete, which became a compliance risk for their SOC 2 certification.
Solution
The team executed a phased migration to GitLab CI/CD over 16 weeks. They began by auditing all Jenkins jobs and categorizing them into tiers based on criticality and complexity. Low-risk utility jobs were migrated first to build confidence. Each Jenkins Groovy pipeline was translated into declarative YAML in .gitlab-ci.yml files stored alongside the application code, giving every developer full visibility into the build process. They replaced Jenkins shared libraries with GitLab CI/CD includes using the include:project and include:template directives, pulling reusable pipeline configurations from a central gitlab-ci-templates repository. Credential management was consolidated into GitLab CI/CD variables at the group and project level, with masked and protected variable features replacing the patchwork Jenkins approach. The team configured GitLab Runners on Kubernetes using the GitLab Runner Helm chart, enabling auto-scaling that dynamically provisioned build pods based on demand. They leveraged GitLab merge request pipelines to run targeted test suites on every push, with pipeline results displayed directly in the merge request widget. Deployment pipelines used GitLab environments with manual approval gates for production, providing a full audit trail. They also implemented pipeline efficiency features like needs keyword for DAG pipelines, rules-based job inclusion, and caching with the cache directive to dramatically reduce build times.
Commands
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash # Add GitLab Runner repository to the package manager
sudo apt-get install gitlab-runner # Install GitLab Runner on the build server
sudo gitlab-runner register --url https://gitlab.meridianfin.com --registration-token $RUNNER_TOKEN --executor kubernetes # Register a runner with Kubernetes executor for autoscaling
helm repo add gitlab https://charts.gitlab.io # Add the official GitLab Helm chart repository
helm install gitlab-runner gitlab/gitlab-runner -f runner-values.yaml -n gitlab-runners # Deploy GitLab Runner into the Kubernetes cluster using Helm
git mv Jenkinsfile .gitlab-ci.yml # Replace the Jenkins pipeline file with GitLab CI/CD configuration
gitlab-ci-lint .gitlab-ci.yml # Validate the new pipeline configuration syntax before committing
glab ci status --branch main # Check the current pipeline status for the main branch
glab ci list --status running # List all currently running pipelines across the project
kubectl get pods -n gitlab-runners -l app=gitlab-runner # Verify runner pods are active and ready in the cluster
Outcome
Build queue wait times dropped from 25 minutes to under 2 minutes with auto-scaling runners. Pipeline maintenance effort was reduced by 75%, freeing both infrastructure engineers to work on product features. Merge request feedback loops improved from 40 minutes to 12 minutes. The team eliminated Jenkins entirely, reducing CI/CD infrastructure costs by 60% and consolidating credential management into a single auditable system that satisfied SOC 2 requirements.
Lessons Learned
Migrate in phases starting with low-risk pipelines to build team confidence. Invest time upfront in designing reusable CI/CD templates in a shared repository, as this pays dividends across all projects. The biggest wins came not from feature parity with Jenkins but from leveraging GitLab-native capabilities like merge request pipelines and environments that Jenkins could never natively provide.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────┐ │ BEFORE: Jenkins Setup │ ├─────────────────────────────────────────────────────────────┤ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ GitLab │───→│ Jenkins │───→│ 14 Controllers │ │ │ │ Repo │ │ Webhooks │ │ 50 Static Agents │ │ │ └──────────┘ └──────────────┘ └──────────────────┘ │ │ ✗ No native MR integration │ │ ✗ Manual agent provisioning │ │ ✗ Scattered credentials │ ├─────────────────────────────────────────────────────────────┤ │ AFTER: GitLab CI/CD │ ├─────────────────────────────────────────────────────────────┤ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ GitLab │───→│ .gitlab-ci │───→│ K8s Runners │ │ │ │ Repo │ │ .yml │ │ (Auto-scaling) │ │ │ └──────────┘ └──────────────┘ └──────────────────┘ │ │ ✓ MR pipeline integration ┌──────────────────┐ │ │ ✓ Auto-scaling runners ────→│ Environments │ │ │ ✓ Centralized variables │ staging → prod │ │ │ └──────────────────┘ │ └─────────────────────────────────────────────────────────────┘
💬 Comments
Context
CloudNova Systems, a SaaS company running 40 microservices on Kubernetes across three clusters (development, staging, production), needed to move from imperative kubectl-based deployments to a declarative GitOps workflow managed entirely through GitLab.
Problem
CloudNova's deployment process relied on developers running kubectl commands directly against clusters or triggering CI/CD jobs that used kubectl apply in pipeline scripts. This imperative approach caused significant drift between what was declared in Git and what was actually running in the clusters. There was no single source of truth for the desired state of any environment. When incidents occurred, the team spent hours determining what had changed and when, because manual kubectl edits left no audit trail in version control. Rollbacks were error-prone, requiring engineers to remember or guess the previous configuration state. Access control was coarse-grained, with too many developers holding cluster-admin credentials. The three-cluster topology multiplied these problems, as each cluster could drift independently. The team also struggled with secret management, often passing Kubernetes secrets through insecure channels or storing them as plain text in CI/CD variables. Deployment frequency had stagnated at twice per week because the process required a senior engineer to supervise every production rollout manually.
Solution
CloudNova implemented a full GitOps workflow using the GitLab Agent for Kubernetes (agentk). They installed the agent in each of the three clusters using the official Helm chart and registered them through GitLab's Kubernetes integration page. A dedicated repository named k8s-manifests was structured with directory-based environment separation: clusters/dev, clusters/staging, and clusters/production. The GitLab Agent was configured with a config.yaml in the .gitlab/agents/<agent-name> directory, defining which manifest paths to watch for each cluster using the gitops.manifest_projects configuration block. When developers merged changes to manifest files, the agent automatically detected the new commit and pulled the desired state into the appropriate cluster, eliminating the need for push-based kubectl commands entirely. They implemented a promotion workflow where changes flowed through merge requests: a developer would update manifests in clusters/dev, and after validation, use GitLab's cherry-pick or merge request feature to promote those changes to clusters/staging and then clusters/production. Each promotion required merge request approval from the platform team, providing a built-in change management process. For secrets, they integrated the GitLab Agent with an external HashiCorp Vault instance, using the Vault integration with GitLab CI/CD's id_tokens for JWT authentication. The team also configured the agent's ci_access module to allow CI/CD pipelines to interact with clusters without storing kubeconfig files, using the agent as a secure tunnel. Drift detection was enabled through the agent's reconciliation loop, which automatically corrected any manual changes made to cluster resources.
Commands
helm repo add gitlab https://charts.gitlab.io # Add GitLab Helm chart repository for agent installation
helm install gitlab-agent gitlab/gitlab-agent --namespace gitlab-agent --set config.token=$AGENT_TOKEN --set config.kasAddress=wss://kas.gitlab.cloudnova.io # Install the GitLab Agent for Kubernetes in the cluster
kubectl create namespace gitlab-agent # Create a dedicated namespace for the GitLab Agent workload
mkdir -p .gitlab/agents/production-agent # Create the agent configuration directory structure in the repository
touch .gitlab/agents/production-agent/config.yaml # Create the agent configuration file for the production cluster
kubectl get gitlabagents -n gitlab-agent # Verify the agent is registered and connected to GitLab
glab repo clone k8s-manifests # Clone the centralized Kubernetes manifests repository
kubectl logs -n gitlab-agent -l app=gitlab-agent -f # Stream agent logs to verify sync operations and troubleshoot issues
glab mr create --source-branch feature/update-api-replicas --target-branch main --title 'Scale API to 5 replicas' # Create a merge request for the manifest change
kubectl get events -n gitlab-agent --sort-by='.lastTimestamp' # Check recent agent events to confirm reconciliation occurred
Outcome
Deployment frequency increased from twice per week to multiple times per day. Cluster drift was completely eliminated as the agent's reconciliation loop corrected any manual changes within 30 seconds. Mean time to recovery (MTTR) dropped by 70% because rollbacks became simple git reverts. The number of engineers with direct cluster credentials was reduced from 25 to 4 platform team members. All environment changes now had a complete audit trail through merge request history in GitLab.
Lessons Learned
The pull-based GitOps model with the GitLab Agent is fundamentally more secure than push-based CI/CD deployments because clusters only need outbound connectivity to GitLab, not inbound access from CI runners. Structuring manifests by environment directory rather than by application made promotion workflows much cleaner. Start with non-production clusters to validate the agent configuration before rolling out to production.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ GitLab GitOps Architecture │ ├───────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────────────┐ │ │ │ Developer │────────→│ k8s-manifests Repo │ │ │ │ Merge Request│ │ ┌────────────────┐ │ │ │ └──────────────┘ │ │ clusters/dev │ │ │ │ │ │ clusters/stg │ │ │ │ │ │ clusters/prod │ │ │ │ │ └────────────────┘ │ │ │ └─────────┬────────────┘ │ │ │ │ │ ┌──────────▼──────────┐ │ │ │ GitLab KAS Server │ │ │ └──────────┬──────────┘ │ │ ┌────────────────┼────────────────┐ │ │ │ │ │ │ │ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ │ │ agentk │ │ agentk │ │ agentk │ │ │ │ (dev) │ │ (stg) │ │ (prod) │ │ │ │ ●──pull │ │ ●──pull │ │ ●──pull │ │ │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ │ │ K8s Dev │ │ K8s Stg │ │ K8s Prod │ │ │ │ Cluster │ │ Cluster │ │ Cluster │ │ │ └───────────┘ └───────────┘ └───────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Context
Vertexline Analytics, a data analytics startup with 60 engineers, maintained a mono-repo containing their frontend application, three backend API services, a shared library, and infrastructure-as-code configurations. All code lived in a single GitLab project.
Problem
Vertexline's mono-repo had a single .gitlab-ci.yml file that had ballooned to over 2,000 lines of YAML. Every merge request triggered the entire pipeline, running all tests and builds for all components regardless of which directory was actually changed. A small CSS fix in the frontend would trigger full backend integration tests, Terraform plan operations, and shared library builds, wasting over 45 minutes of compute time per pipeline run. The team was burning through their GitLab CI/CD minutes rapidly, and runner queues were constantly saturated. Pipeline failures were difficult to diagnose because the monolithic pipeline contained over 80 jobs with complex dependencies defined using needs and dependencies keywords that had grown organically and were often circular or redundant. Adding a new service to the mono-repo required modifying the root pipeline in multiple places, a process so error-prone that it typically took three or four attempts to get right. The YAML anchors and extends patterns used to reduce duplication had reached their limits, creating deeply nested inheritance chains that were nearly impossible to reason about.
Solution
The team restructured their CI/CD using GitLab's parent-child pipeline architecture with the trigger keyword. The root .gitlab-ci.yml was reduced to a lightweight orchestrator of approximately 50 lines that used rules:changes to detect which directories had modifications and conditionally triggered child pipelines for only the affected components. Each component directory received its own .gitlab-ci.yml file: frontend/.gitlab-ci.yml, api-gateway/.gitlab-ci.yml, analytics-engine/.gitlab-ci.yml, recommendation-service/.gitlab-ci.yml, shared-lib/.gitlab-ci.yml, and infra/.gitlab-ci.yml. The parent pipeline used trigger:include to spawn child pipelines with the trigger:strategy: depend setting, ensuring the parent pipeline's status accurately reflected child pipeline outcomes. For the shared library, they implemented a special pattern: when shared-lib files changed, the parent pipeline triggered the shared library build first, and upon success, used the needs keyword with the trigger jobs to fan out and trigger all dependent service pipelines automatically. Each child pipeline was fully self-contained with its own stages, caches, and artifacts configurations, allowing teams to modify their pipeline logic without affecting other components. They used GitLab's rules:changes:paths with glob patterns to precisely detect affected files, and implemented a compare_to: refs/heads/main configuration to compare changes against the main branch rather than the previous commit. Pipeline efficiency was further improved by implementing component-specific Docker image caching using the GitLab Container Registry and the --cache-from flag in their Kaniko-based builds within child pipelines.
Commands
cat .gitlab-ci.yml | grep -c 'trigger:' # Count the number of child pipeline triggers in the parent pipeline
glab ci list --per-page 20 # List recent pipelines to verify only affected components are running
glab ci view $PIPELINE_ID # View detailed status of a specific parent pipeline and its child pipelines
git diff --name-only origin/main...HEAD | cut -d'/' -f1 | sort -u # Identify which top-level directories have changes to predict which child pipelines will run
gitlab-ci-lint frontend/.gitlab-ci.yml # Validate the frontend child pipeline configuration independently
glab ci run --branch feature/api-refactor --variables FORCE_COMPONENT=api-gateway # Manually trigger a pipeline with a variable to force a specific child pipeline to run
glab ci artifact download --job build-frontend $PIPELINE_ID # Download artifacts from a specific child pipeline job
grep -r 'trigger:' .gitlab-ci.yml --include='*.yml' # Find all trigger definitions across pipeline files
glab ci get --pipeline-id $CHILD_PIPELINE_ID # Inspect a child pipeline's details including trigger source
find . -name '.gitlab-ci.yml' -maxdepth 2 | sort # List all pipeline configuration files in the mono-repo
Outcome
Average pipeline duration dropped from 45 minutes to 8 minutes for typical merge requests that touched a single component. CI/CD minute consumption was reduced by 72% month-over-month. The root pipeline file went from 2,000 lines to 50 lines. Onboarding new services became a 30-minute task instead of a multi-day effort. Each team gained full ownership of their component's pipeline, reducing cross-team dependencies for CI/CD changes to near zero.
Lessons Learned
Parent-child pipelines are the correct pattern for mono-repos in GitLab, but the rules:changes configuration must be carefully designed to avoid both false positives and false negatives. Always use trigger:strategy: depend so that parent pipeline status accurately reflects child outcomes. Consider a dedicated changes detection job that generates a dynamic child pipeline list using trigger:include with artifact-based YAML generation for maximum flexibility.
◈ Architecture Diagram
┌────────────────────────────────────────────────────────────────┐ │ Parent-Child Pipeline Architecture │ ├────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Root .gitlab-ci.yml (Parent Pipeline) │ │ │ │ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐ │ │ │ │ │ detect │─→│ trigger │─→│ trigger │─→│ trigger │ │ │ │ │ │ changes │ │ if chg │ │ if chg │ │ if chg │ │ │ │ │ └─────────┘ └────┬────┘ └────┬─────┘ └─────┬─────┘ │ │ │ └────────────────────┼───────────┼────────────────┼────────┘ │ │ │ │ │ │ │ ┌──────────▼──┐ ┌────▼────────┐ ┌───▼─────────┐ │ │ │ frontend/ │ │ api-gateway/│ │ analytics/ │ │ │ │ .gitlab-ci │ │ .gitlab-ci │ │ .gitlab-ci │ │ │ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │ │ │ │ build │ │ │ │ build │ │ │ │ build │ │ │ │ │ │ test │ │ │ │ test │ │ │ │ test │ │ │ │ │ │ deploy│ │ │ │ deploy│ │ │ │ deploy│ │ │ │ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ ● Only changed components run ✓ Independent pipelines │ │ ● 72% fewer CI minutes ✓ Team-owned configurations │ └────────────────────────────────────────────────────────────────┘
💬 Comments
Context
BrightPath Health, a digital health company with 35 developers, operated 12 microservices deployed to Kubernetes. They had been using Docker Hub as their container registry and a mix of manual docker build commands and partial CI automation to manage container images.
Problem
BrightPath's container image management was chaotic. Developers built images locally on their machines with inconsistent Docker versions and pushed them to a shared Docker Hub organization account using a single set of credentials. There was no standardized tagging strategy; some teams used latest, others used commit SHAs, and some used arbitrary version numbers, making it impossible to trace a running container back to its source code. Docker Hub rate limiting caused pipeline failures during peak hours, as all 12 services pulled base images concurrently. Image vulnerability scanning was performed manually and sporadically, meaning known CVEs often made it to production. The team had no policy for cleaning up old images, and their Docker Hub storage grew unchecked. Multi-architecture builds for ARM-based development machines and AMD64 production servers were handled inconsistently, with some services lacking ARM images entirely. The disconnect between the container registry and the source code repository meant there was no linkage between a merge request and the image it produced, complicating debugging and rollback decisions.
Solution
BrightPath migrated their entire container workflow to the GitLab Container Registry, which is built into every GitLab project. Each microservice repository had its pipeline configured to build, tag, scan, and push images to the project's own registry at registry.gitlab.brightpath.io/group/project. They standardized the build process using Kaniko within GitLab CI/CD, eliminating the need for Docker-in-Docker and the associated privileged runner security concerns. The .gitlab-ci.yml for each service included a multi-stage pipeline: the build stage used Kaniko to construct the image with layer caching against the registry using --cache=true and --cache-repo flags, dramatically reducing build times for incremental changes. A standardized tagging strategy was enforced through CI/CD variables: every image received three tags automatically using the CI_COMMIT_SHA, CI_COMMIT_REF_SLUG, and a semantic version extracted from a VERSION file in the repository. The built-in GitLab Container Scanning analyzer was added as a pipeline job using the include template Container-Scanning.gitlab-ci.yml, which automatically scanned every image for CVEs and reported findings directly in the merge request security widget. Images with critical vulnerabilities were blocked from merging using merge request approval rules tied to the security scan results. A cleanup policy was configured through the GitLab Container Registry's built-in tag expiration feature, automatically removing images older than 90 days while preserving tagged releases. For multi-architecture support, they configured Kaniko with the --customPlatform flag and used a matrix strategy in the pipeline to build both linux/amd64 and linux/arm64 variants, pushing multi-arch manifests to the registry. The tight integration between GitLab CI/CD and the Container Registry meant that every merge request displayed the exact image tags produced by its pipeline, creating full traceability from code to running container.
Commands
docker login registry.gitlab.brightpath.io -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD # Authenticate to the GitLab Container Registry in the CI pipeline
echo $CI_REGISTRY_PASSWORD | /kaniko/executor --context $CI_PROJECT_DIR --dockerfile Dockerfile --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --cache=true --cache-repo=$CI_REGISTRY_IMAGE/cache # Build and push image using Kaniko with layer caching enabled
glab api projects/$CI_PROJECT_ID/registry/repositories # List all container repositories in the GitLab project registry
glab api projects/$CI_PROJECT_ID/registry/repositories/$REPO_ID/tags # List all tags for a specific container image in the registry
skopeo inspect docker://$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Inspect the metadata of a pushed container image without pulling it
trivy image --severity HIGH,CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Run additional vulnerability scanning against the built image
glab ci run --variables DEPLOY_IMAGE=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA # Trigger a deployment pipeline with a specific image reference
crane tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA v2.3.1 # Add a semantic version tag to an existing image without re-pushing layers
glab api -X DELETE projects/$CI_PROJECT_ID/registry/repositories/$REPO_ID/tags/old-tag # Delete a specific image tag from the GitLab Container Registry
kubectl set image deployment/api-gateway api-gateway=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n production # Update a Kubernetes deployment to use the newly built image
Outcome
Container build times were reduced by 65% due to Kaniko layer caching against the GitLab registry. Docker Hub rate limiting issues were completely eliminated. Vulnerability scanning caught 23 critical CVEs in the first month that had previously been deployed to production. Image traceability from merge request to running container achieved 100% coverage. Storage costs decreased by 40% due to automated tag expiration policies. All developers could now build ARM and AMD64 images consistently through CI/CD.
Lessons Learned
Using GitLab's built-in Container Registry eliminates an entire class of authentication, rate-limiting, and traceability problems. Kaniko is strongly preferred over Docker-in-Docker for GitLab CI builds because it does not require privileged runners. Enforce a tagging convention from day one; retroactively fixing inconsistent tags across 12 services was the most time-consuming part of the migration. The Container Scanning template is trivially easy to add but delivers enormous security value.
◈ Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐ │ GitLab Container Registry CI/CD Flow │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌────────────────────────────────────────────┐ │ │ │ Developer │───→│ Merge Request Pipeline │ │ │ │ git push │ │ │ │ │ └──────────┘ │ ┌───────┐ ┌────────┐ ┌──────────────┐ │ │ │ │ │ Build │─→│ Scan │─→│ Push Tags │ │ │ │ │ │Kaniko │ │ CVEs │ │ SHA/slug/ver │ │ │ │ │ └───────┘ └───┬────┘ └──────┬───────┘ │ │ │ └─────────────────┼──────────────┼──────────┘ │ │ │ │ │ │ ┌────────▼────────┐ │ │ │ │ MR Security │ │ │ │ │ Widget │ │ │ │ │ ✓ 0 Critical │ │ │ │ │ ✗ Block if CVE │ │ │ │ └─────────────────┘ │ │ │ │ │ │ ┌───────────────────────────────▼───────────┐ │ │ │ GitLab Container Registry │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────────┐ │ │ │ │ │ :sha │ │ :branch │ │ :v2.3.1 │ │ │ │ │ │ amd64 │ │ amd64 │ │ multi-arch │ │ │ │ │ │ arm64 │ │ arm64 │ │ manifest │ │ │ │ │ └─────────┘ └─────────┘ └─────────────┘ │ │ │ │ ● Auto-cleanup > 90 days │ │ │ └──────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
💬 Comments
Context
Aegis Pharmaceuticals, a biotech company with 200 developers building clinical trial management software, needed to meet FDA 21 CFR Part 11 and EU GxP compliance requirements. Every software change to their validated systems required documented evidence of review, testing, and approval before deployment.
Problem
Aegis had been managing compliance documentation manually. For each software release, a quality assurance specialist spent an average of three days compiling evidence packages that included code review records, test execution results, approval signatures, and deployment logs. These documents were assembled in Word and PDF format, stored in a SharePoint site that had grown to contain over 8,000 compliance artifacts with inconsistent naming conventions. During FDA audits, the team struggled to trace from a deployed software version back through its complete change history, often requiring days of cross-referencing spreadsheets, email threads, and SharePoint folders. The manual process was error-prone; in the previous audit cycle, two releases were found to have incomplete approval documentation, resulting in formal observations. Development teams frequently bypassed the process entirely for urgent fixes, creating compliance gaps that were only discovered during periodic internal audits. The lack of automated enforcement meant that compliance depended entirely on individual discipline rather than systemic controls. Test evidence was particularly problematic, as teams used different testing frameworks, and consolidating results into the required format was largely manual work.
Solution
Aegis implemented GitLab compliance pipelines using the compliance_pipeline configuration available in GitLab Ultimate. A central compliance-pipeline-templates group was created, owned by the quality assurance and regulatory affairs team. This group contained compliance framework definitions that were associated with all regulated projects through GitLab's compliance framework feature at the group level. The compliance pipeline configuration used the include directive to inject mandatory pipeline stages into every project's pipeline, regardless of what the development team defined in their own .gitlab-ci.yml. These mandatory stages included automated code quality gates using GitLab Code Quality, SAST scanning using the GitLab SAST template, dependency scanning for known vulnerabilities, and a documentation generation stage. The documentation stage automatically generated compliance evidence packages by querying the GitLab API for merge request approvals, code review comments, test results, and pipeline artifacts, compiling them into structured JSON documents stored as pipeline artifacts. Merge request approval rules were configured to require at least two approvals from designated code owners, plus a mandatory approval from a member of the QA group, enforced through GitLab's protected branch settings and CODEOWNERS file. The compliance pipeline included a validation job that verified all required approvals were present and all mandatory scan results were clean before allowing the deployment stage to execute, using GitLab's allow_failure: false and needs dependencies to create hard gates. Audit events were captured using GitLab's audit event streaming feature, forwarding all project and group-level events to a centralized SIEM system for long-term retention. Each deployment to production automatically created a GitLab release with auto-generated release notes from merge request titles, linking to the full evidence package artifact. The team also configured GitLab's push rules to prevent force pushes and require commit signing with GPG keys, ensuring non-repudiation for all code changes.
Commands
glab api groups/$GROUP_ID/compliance_frameworks # List all compliance frameworks defined for the group
glab api projects/$PROJECT_ID/approval_rules # View the merge request approval rules configured for the project
glab api projects/$PROJECT_ID/protected_branches # Verify protected branch settings enforce compliance controls
glab api projects/$PROJECT_ID/audit_events?created_after=2026-01-01 # Retrieve audit events for the project since the start of the year
glab mr list --label compliance-review --state merged # List all merged merge requests that went through compliance review
glab release create v3.2.0 --notes-file RELEASE_NOTES.md --assets-links '[{"name":"evidence","url":"https://gitlab.aegis.io/evidence/v3.2.0.json"}]' # Create a release with linked compliance evidence artifactsglab ci artifact download --job compliance-evidence $PIPELINE_ID # Download the auto-generated compliance evidence package from the pipeline
glab api projects/$PROJECT_ID/merge_requests/$MR_IID/approvals # Check approval status for a specific merge request to verify compliance sign-off
glab api projects/$PROJECT_ID/push_rule # Verify push rules enforce commit signing and prevent force pushes
glab api groups/$GROUP_ID/audit_events --per-page 100 # Pull group-level audit events for regulatory audit preparation
Outcome
Compliance evidence package preparation time dropped from 3 days per release to fully automated, zero manual effort. The FDA audit in Q3 2026 resulted in zero observations related to software change control for the first time in company history. Developer velocity increased by 30% because compliance gates were now automated rather than blocking on manual QA review. 100% of production deployments had complete, traceable evidence packages. The centralized audit event stream provided instant answers to auditor queries that previously took days to compile.
Lessons Learned
Compliance pipelines must be owned by the compliance team, not development teams, to maintain independence and prevent circumvention. GitLab's compliance framework feature is essential for regulated environments because it injects mandatory stages that project-level configurations cannot override or skip. Automating evidence collection is the single highest-ROI compliance investment, as it simultaneously improves audit readiness and removes friction from the development process. Start with audit event streaming early, as historical data cannot be retroactively generated.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Compliance Pipeline Architecture │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ Compliance Framework (owned by QA/Regulatory team) │ │ │ │ ┌─────────┐ ┌──────────┐ ┌────────┐ ┌─────────────┐ │ │ │ │ │ SAST │ │ Code │ │ Dep │ │ Evidence │ │ │ │ │ │ Scan │ │ Quality │ │ Scan │ │ Generation │ │ │ │ │ └────┬────┘ └────┬─────┘ └───┬────┘ └──────┬──────┘ │ │ │ └───────┼────────────┼────────────┼──────────────┼─────────┘ │ │ │ │ │ │ │ │ ┌───────▼────────────▼────────────▼──────────────▼───────────┐ │ │ │ Project Pipeline (.gitlab-ci.yml) │ │ │ │ ┌───────┐ ┌───────┐ ┌──────────┐ ┌──────────────────┐ │ │ │ │ │ Build │─→│ Test │─→│ Approval │─→│ Deploy │ │ │ │ │ │ │ │ │ │ Gate │ │ (blocked until │ │ │ │ │ │ │ │ │ │ ✓ 2 devs │ │ all gates pass) │ │ │ │ │ │ │ │ │ │ ✓ 1 QA │ │ │ │ │ │ │ └───────┘ └───────┘ └──────────┘ └────────┬─────────┘ │ │ │ └────────────────────────────────────────────────┼───────────┘ │ │ │ │ │ ┌────────────────────────────────────────────────▼───────────┐ │ │ │ Evidence Package (auto-generated artifact) │ │ │ │ ● MR approvals ● Test results ● Scan reports │ │ │ │ ● Commit signatures ● Audit event log │ │ │ └────────────────────────────────────────────────────────────┘ │ │ │ │ │ ┌──────────▼──────────┐ │ │ │ GitLab Release │ │ │ │ + Evidence Link │ │ │ └─────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Context
Mosaic Digital Agency, a web development agency with 45 developers working across 15 client projects simultaneously, needed a way for designers, project managers, and clients to preview feature branches before they were merged, without requiring any technical setup.
Problem
Mosaic's review process was severely bottlenecked. When a developer completed a feature, they would record a screencast or share screenshots in the merge request, which was often insufficient for interactive features like forms, animations, or responsive layouts. Project managers and designers would request access to the developer's local environment through screen sharing, which was disruptive and could only happen synchronously. For client reviews, the team maintained two shared staging servers, but with 15 active projects and dozens of feature branches in flight at any time, these servers became constant sources of conflict. Developers would deploy their branch to staging, only to have it overwritten by another developer minutes later. This led to a informal reservation system managed through a Slack channel that was ignored as often as it was followed. Client feedback cycles stretched to weeks because scheduling a demo required coordinating developer availability with client availability. The staging environment was also not representative of production, as it ran a single instance without SSL, leading to bugs that only appeared after merge. Some features required specific test data configurations that could not coexist on a shared staging server, further limiting what could be reviewed.
Solution
Mosaic implemented GitLab Review Apps using dynamic environments that automatically deployed every merge request branch to its own isolated, publicly accessible URL. The .gitlab-ci.yml was configured with a deploy_review job in the deploy stage that used the environment keyword with dynamic naming: environment: name: review/$CI_COMMIT_REF_SLUG and a dynamically generated URL: url: https://$CI_COMMIT_REF_SLUG.$CI_PROJECT_NAME.review.mosaic.dev. The infrastructure was built on a Kubernetes cluster with an Nginx Ingress Controller and cert-manager for automatic Let's Encrypt SSL certificates, ensuring every review app had HTTPS identical to production. Each review app deployment created a new Kubernetes namespace using the merge request branch slug, deployed the application using Helm with branch-specific values, and seeded a dedicated database instance with test data appropriate for the feature being reviewed. The review app URL was automatically posted as a comment in the merge request by GitLab's environment integration, and a direct link appeared in the merge request widget under the Environments section, requiring zero technical knowledge to access. When the merge request was merged or closed, the stop_review job was triggered automatically using environment: action: stop and the on_stop keyword, tearing down the Kubernetes namespace, deleting the database, and removing the DNS record. This ensured no orphaned resources accumulated. For projects requiring backend API integration, the review app deployment included both frontend and API containers in the same namespace with service discovery configured through Kubernetes Services. The team also implemented a GitLab CI/CD schedule that ran nightly to garbage-collect any review environments that had been running longer than 7 days, as a safety net against leaked resources. Performance was addressed by using resource-limited Kubernetes deployments for review apps, with each environment capped at 256Mi memory and 200m CPU to prevent any single review app from impacting cluster stability.
Commands
kubectl get namespaces | grep review- # List all active review app namespaces in the Kubernetes cluster
glab mr list --label has-review-app --state opened # List all open merge requests that have active review apps
kubectl get ingress -n review-feature-login-redesign # Check the ingress configuration for a specific review app
glab ci run --variables STOP_REVIEW=true --branch feature-login-redesign # Manually trigger teardown of a specific review app environment
helm list -n review-feature-login-redesign # List Helm releases deployed in a review app namespace
glab api projects/$CI_PROJECT_ID/environments?search=review # Search for all review environments in the project
kubectl logs -n review-feature-login-redesign -l app=frontend --tail=50 # View recent logs from a review app frontend container
glab api -X DELETE projects/$CI_PROJECT_ID/environments/$ENV_ID # Delete a stopped review environment from GitLab
curl -s -o /dev/null -w '%{http_code}' https://feature-login-redesign.clientsite.review.mosaic.dev # Quick health check on a review app URLkubectl get pods -n review-feature-login-redesign -o wide # Check pod status and node placement for a review app
Outcome
Client feedback cycle time was reduced from weeks to hours. The two contested staging servers were decommissioned entirely. Merge requests with review app links received feedback 4x faster than those with only screenshots. Designers reported 85% fewer post-merge visual bugs because they could interact with real implementations before approval. The agency won three new client contracts partly due to demonstrating their review app workflow during sales pitches. Resource cleanup was 100% automated with zero orphaned environments over 6 months of operation.
Lessons Learned
Review apps are most powerful when they require zero effort from non-technical reviewers. The URL must be predictable and the link must appear automatically in the merge request. Always implement the stop_review job with on_stop to prevent resource leaks. Resource limits on review app pods are essential for cluster stability when running dozens of environments simultaneously. Seeding review app databases with meaningful test data is worth the investment, as empty applications are useless for real review.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────┐ │ Review Apps Dynamic Environment Flow │ ├───────────────────────────────────────────────────────────────┤ │ │ │ Developer pushes to feature branch │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ MR Pipeline │ │ │ │ ┌───────┐ ┌──────┐ ┌────────────────────────────┐ │ │ │ │ │ Build │─→│ Test │─→│ deploy_review │ │ │ │ │ └───────┘ └──────┘ │ ● Create namespace │ │ │ │ │ │ ● Helm install │ │ │ │ │ │ ● Seed database │ │ │ │ │ │ ● Configure SSL via │ │ │ │ │ │ cert-manager │ │ │ │ │ └─────────────┬──────────────┘ │ │ │ └─────────────────────────────────────┼────────────────┘ │ │ │ │ │ ┌─────────▼─────────────┐ │ │ │ Review Environment │ │ │ │ https://feature-xyz │ │ │ │ .project.review │ │ │ │ .mosaic.dev │ │ │ └─────────┬─────────────┘ │ │ │ │ │ ┌───────────────────┼──────────────────┐ │ │ │ │ │ │ │ ┌─────▼─────┐ ┌──────────▼────┐ ┌────────▼┐ │ │ │ Designer │ │ Project Mgr │ │ Client │ │ │ │ reviews │ │ approves │ │ tests │ │ │ └───────────┘ └───────────────┘ └─────────┘ │ │ │ │ MR merged or closed │ │ │ │ │ ▼ │ │ ┌──────────────┐ │ │ │ stop_review │──→ Delete namespace, DB, DNS │ │ │ (automatic) │ │ │ └──────────────┘ │ └───────────────────────────────────────────────────────────────┘
💬 Comments
Context
NexaGrid Energy, a renewable energy company with a platform team of 8 engineers, managed cloud infrastructure across AWS and GCP for their IoT sensor data pipeline and customer-facing dashboard. They had 15 Terraform root modules managing networking, compute, databases, and Kubernetes clusters.
Problem
NexaGrid's Terraform state management was a persistent source of risk and friction. State files were stored in an S3 bucket with minimal access controls, and the team had experienced two incidents where concurrent terraform apply operations corrupted the state file, requiring manual state surgery that took an entire day to resolve. The S3 backend lacked native integration with their development workflow, so there was no connection between a merge request proposing infrastructure changes and the Terraform plan output for those changes. Engineers would run terraform plan locally, paste the output into a merge request comment manually, and then run terraform apply from their laptops after the merge request was approved. This local execution model meant that different engineers had different versions of Terraform, different provider plugins, and different environment variable configurations, leading to inconsistent results. State locking via DynamoDB was configured but frequently failed due to stale locks left by interrupted operations, requiring manual intervention. There was no centralized visibility into what infrastructure was managed, what its current state was, or when it was last modified. Secret management for Terraform variables containing API keys and database passwords was handled through a shared .env file in a private Slack channel.
Solution
NexaGrid migrated all Terraform state management to GitLab's built-in Terraform state backend, which is available as an HTTP backend integrated directly into GitLab projects. Each of the 15 root modules was configured to use the GitLab HTTP backend with the address pointing to the project's Terraform state API endpoint. State locking was handled natively by GitLab, eliminating the DynamoDB dependency and the stale lock issues. The team created a standardized .gitlab-ci.yml template for Terraform workflows using GitLab's official Terraform CI/CD template included via include: template: Terraform.latest.gitlab-ci.yml. This template provided pre-built stages for fmt, validate, plan, and apply. The plan stage ran automatically on every merge request, generating a Terraform plan output that was stored as a pipeline artifact and rendered directly in the merge request widget using the terraform_report artifact type. Reviewers could see exactly what infrastructure changes would occur before approving, without anyone running commands locally. The apply stage was configured to run only on the default branch after merge, using rules: if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH, ensuring that infrastructure changes were applied through a consistent CI/CD environment with pinned Terraform and provider versions defined in the pipeline's Docker image. Sensitive variables like cloud provider credentials and API keys were stored in GitLab CI/CD variables with the masked and protected flags, accessible only to pipelines running on protected branches. The team also implemented Terraform module versioning by publishing shared modules to a private GitLab Terraform Module Registry using the Terraform module registry feature, allowing teams to consume infrastructure modules with semantic versioning. For visibility, they used the GitLab Terraform state list API to build a dashboard showing all managed states, their sizes, last modification times, and lock status. Policy enforcement was added using a validate stage that ran tfsec and checkov security scanners, blocking merge requests that introduced non-compliant resource configurations such as publicly accessible S3 buckets or unencrypted databases.
Commands
terraform init -backend-config="address=https://gitlab.nexagrid.io/api/v4/projects/$PROJECT_ID/terraform/state/production" -backend-config="lock_address=https://gitlab.nexagrid.io/api/v4/projects/$PROJECT_ID/terraform/state/production/lock" -backend-config="username=gitlab-ci-token" -backend-config="password=$CI_JOB_TOKEN" # Initialize Terraform with GitLab HTTP backend for state management
glab api projects/$PROJECT_ID/terraform/state # List all Terraform states managed by this GitLab project
glab api projects/$PROJECT_ID/terraform/state/production # Get details about the production Terraform state including lock status
glab api -X DELETE projects/$PROJECT_ID/terraform/state/production/lock # Force-unlock a Terraform state if a stale lock exists
terraform plan -out=plan.cache # Generate a Terraform plan and save it as a binary file for later apply
terraform show --json plan.cache | jq '.' > plan.json # Convert the binary plan to JSON for GitLab's terraform report artifact
terraform apply plan.cache # Apply the exact plan that was reviewed and approved in the merge request
glab api projects/$PROJECT_ID/packages?package_type=terraform_module # List published Terraform modules in the project's module registry
tfsec . --format json --out tfsec-report.json # Run security scanning on Terraform configurations and output results as JSON
glab mr create --source-branch infra/resize-rds --title 'Scale RDS to db.r6g.xlarge' --label terraform # Create a merge request for an infrastructure change with appropriate label
Outcome
Zero state corruption incidents since migration, compared to two per quarter previously. Terraform plan review time decreased from 30 minutes of manual copying to instant, as plans appeared automatically in merge requests. Local terraform apply executions dropped to zero, ensuring 100% of infrastructure changes ran through CI/CD with consistent tooling. The Terraform Module Registry enabled 60% code reuse across root modules. Security scanner integration blocked 12 non-compliant infrastructure changes in the first quarter that would have previously reached production.
Lessons Learned
GitLab's Terraform state backend is production-ready and eliminates the operational overhead of managing S3+DynamoDB backends. The terraform_report artifact type is a game-changer for infrastructure reviews because non-Terraform-expert reviewers can understand the impact of changes. Pinning Terraform and provider versions in the CI/CD Docker image is non-negotiable for reproducibility. Publishing shared modules to the GitLab Terraform Module Registry enforces versioning discipline that file-path-based module references cannot provide.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────┐ │ GitLab Terraform State Management Flow │ ├──────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────────────────────────────────┐ │ │ │Developer │───→│ Merge Request │ │ │ │ tf code │ │ ┌────────────────────────────────┐ │ │ │ └──────────┘ │ │ Pipeline (auto-triggered) │ │ │ │ │ │ ┌─────┐ ┌────────┐ ┌────────┐ │ │ │ │ │ │ │ fmt │→│validate│→│ plan │ │ │ │ │ │ │ └─────┘ └────────┘ └───┬────┘ │ │ │ │ │ └────────────────────────┼───────┘ │ │ │ │ │ │ │ │ │ ┌────────────────────────▼────────┐ │ │ │ │ │ Terraform Plan Report Widget │ │ │ │ │ │ + 3 to add │ │ │ │ │ │ ~ 1 to change │ │ │ │ │ │ - 0 to destroy │ │ │ │ │ └────────────────────────────────┘ │ │ │ └──────────────────┬───────────────────┘ │ │ │ merge │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Main Branch Pipeline │ │ │ │ ┌──────────┐ ┌──────────────────────────────────┐ │ │ │ │ │ apply │───→│ GitLab Terraform State Backend │ │ │ │ │ │ (auto) │ │ ● Native state locking │ │ │ │ │ └──────────┘ │ ● Version history │ │ │ │ │ │ ● API-accessible │ │ │ │ └──────────────────┴──────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ GitLab Terraform Module Registry │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ │ │ │ vpc v1.2 │ │ rds v2.0 │ │ k8s-cluster v3.1│ │ │ │ │ └──────────┘ └──────────┘ └──────────────────┘ │ │ │ └──────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘
💬 Comments
Context
Prism Retail Solutions, a retail technology company with 180 developers across 8 product teams, had been running a fragmented toolchain of Jira for project management, GitHub for source code, Jenkins for CI/CD, Artifactory for artifact storage, and PagerDuty for incident management. The tools were integrated through a web of custom webhooks and scripts.
Problem
Prism's multi-tool ecosystem had become an integration nightmare. The engineering platform team maintained over 40 custom webhook integrations and three middleware applications that synchronized data between Jira, GitHub, and Jenkins. When a developer closed a Jira ticket, a webhook was supposed to update the corresponding GitHub pull request and trigger a Jenkins build, but these integrations failed silently at least twice per week, creating data inconsistencies across systems. Developers context-switched between five different tools throughout the day, each with its own authentication, its own notification system, and its own mental model. Onboarding a new developer required provisioning accounts and configuring SSO across all five platforms, a process that took IT two full days. License costs were staggering: Jira Premium at $14 per user per month, GitHub Enterprise at $21 per user per month, Jenkins required two full-time infrastructure engineers costing the equivalent of $280,000 annually, Artifactory Pro at $6,500 per year, and PagerDuty at $29 per user per month for on-call responders. Total annual tooling spend exceeded $850,000. Beyond cost, the fragmentation created dangerous visibility gaps: project managers could see Jira boards but not deployment status, developers could see GitHub PRs but not incident history, and operations could see PagerDuty alerts but not the code changes that caused them. Security reviews required correlating data from three systems manually, and compliance audits consumed weeks because audit trails were scattered across disconnected platforms.
Solution
Prism executed a 6-month migration to GitLab Ultimate as their single DevSecOps platform, replacing all five tools. The migration began with a detailed feature mapping exercise that identified which GitLab capabilities replaced each tool's functions. Jira projects were migrated to GitLab Issues using the built-in Jira importer, which preserved issue history, comments, and attachments. Jira boards were recreated as GitLab Issue Boards with custom label-based columns matching the team's existing workflow stages. Epics and roadmap views in Jira were replaced with GitLab Epics at the group level, providing the same hierarchical work item tracking with the added benefit of linking directly to merge requests and pipelines. GitHub repositories were migrated using GitLab's repository import feature, which brought over all branches, tags, pull request history as merged merge requests, and wiki content. Existing GitHub Actions workflows were rewritten as .gitlab-ci.yml pipelines, with shared CI/CD templates stored in a central group and distributed using the include:project mechanism. Jenkins was decommissioned entirely, with all build logic migrated to GitLab CI/CD using auto-scaling GitLab Runners on Kubernetes, replicating and improving upon the Jenkins agent infrastructure. Artifactory was replaced by GitLab's built-in Package Registry, which natively supports npm, Maven, PyPI, NuGet, and generic packages, and the Container Registry for Docker images. PagerDuty was replaced by GitLab's Incident Management features, including on-call schedules, escalation policies, and incident timelines, all integrated directly into the projects where the code and pipelines lived. The team implemented GitLab's Value Stream Analytics to gain end-to-end visibility from issue creation through deployment to production, a view that was impossible with fragmented tools. Single sign-on was configured through GitLab's SAML integration with their existing Okta identity provider, reducing account provisioning to a single group membership change. Security scanning was enabled across all projects using GitLab's Auto DevOps security templates, consolidating SAST, DAST, dependency scanning, and secret detection into the merge request workflow.
Commands
glab project import --from jira --jira-url https://prism.atlassian.net --jira-token $JIRA_TOKEN --project $JIRA_PROJECT_KEY # Import a Jira project's issues, comments, and attachments into GitLab
glab project create --import-url https://github.com/prism-retail/checkout-service.git --name checkout-service # Import a GitHub repository including branches, tags, and PR history into GitLab
glab issue board list --group prism-retail # List all issue boards configured for the group to verify Jira board migration
glab issue list --label 'workflow::In Review' --group prism-retail # Query issues by workflow label to replicate Jira board column views
glab api groups/$GROUP_ID/epics # List all epics in the group to verify Jira epic migration
glab ci run --variables DEPLOY_ENV=production --branch main # Trigger a production deployment pipeline, replacing the Jenkins job trigger
glab api projects/$PROJECT_ID/packages # List packages published to the GitLab Package Registry, replacing Artifactory queries
glab incident list --state opened # List open incidents managed through GitLab's Incident Management, replacing PagerDuty dashboard
glab api groups/$GROUP_ID/analytics/value_stream_analytics/stages # View Value Stream Analytics stages to measure end-to-end delivery metrics
glab api groups/$GROUP_ID/billable_members # List billable members to track license utilization across the platform
Outcome
Annual tooling costs dropped from $850,000 to $340,000 with GitLab Ultimate licensing, a 60% reduction. Developer context switching was reduced by eliminating four separate tools and their associated logins and notification channels. New developer onboarding dropped from 2 days to 2 hours with single SSO provisioning. The platform team reclaimed the two full-time engineers previously dedicated to Jenkins maintenance. Value Stream Analytics revealed that the average cycle time from issue creation to production deployment was 14 days, and within 3 months of consolidation, this was reduced to 6 days through visibility-driven process improvements. Security scanning coverage went from approximately 30% of repositories to 100% overnight by enabling group-level security templates.
Lessons Learned
Tool consolidation is as much an organizational change as a technical one. Secure executive sponsorship before starting, because teams will resist changing tools they are comfortable with. Migrate one team completely before attempting a broader rollout to identify issues early and create internal champions. The Jira importer handles most data well, but custom Jira fields and complex automation rules require manual recreation in GitLab. The biggest unexpected benefit was not cost savings but the elimination of integration maintenance, which had been an invisible tax on engineering productivity. GitLab's single data model means that every metric, from code review time to deployment frequency, is available without building custom dashboards or ETL pipelines.
◈ Architecture Diagram
┌───────────────────────────────────────────────────────────────────┐ │ BEFORE: Fragmented Toolchain │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────┐ ┌────────┐ ┌─────────┐ ┌───────────┐ ┌─────────┐ │ │ │ Jira │ │ GitHub │ │ Jenkins │ │Artifactory│ │PagerDuty│ │ │ │ $14/u │ │ $21/u │ │ $280K/yr│ │ $6.5K/yr │ │ $29/u │ │ │ └───┬───┘ └───┬────┘ └────┬────┘ └─────┬─────┘ └────┬────┘ │ │ │ │ │ │ │ │ │ └─────┬────┴──────┬─────┴──────┬───────┘ │ │ │ │ │ │ │ │ │ ┌─────▼───────────▼────────────▼─────────────────────▼───┐ │ │ │ 40 Custom Webhooks + 3 Middleware Apps │ │ │ │ ✗ Silent failures ✗ Data inconsistency │ │ │ │ ✗ $850K annual cost ✗ 2-day onboarding │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ ├───────────────────────────────────────────────────────────────────┤ │ AFTER: GitLab Ultimate │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ GitLab Ultimate │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ │ │ │ Issues │ │ Repos │ │ CI/CD │ │ Packages │ │ │ │ │ │ Boards │ │ MRs │ │ Runners │ │ Registry │ │ │ │ │ │ Epics │ │ Wiki │ │ Envs │ │ Container │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────────────────────┐ │ │ │ │ │Incidents │ │ Security │ │ Value Stream Analytics │ │ │ │ │ │ On-call │ │ SAST/DAST│ │ End-to-end visibility │ │ │ │ │ └──────────┘ └──────────┘ └──────────────────────────┘ │ │ │ │ ✓ Single SSO ✓ $340K/yr ✓ 2-hour onboarding │ │ │ │ ✓ Zero integration maintenance ✓ 100% security coverage │ │ │ └─────────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────────┘
💬 Comments
Symptom
CI/CD pipelines queue indefinitely with runners showing 'Pending' status in the GitLab Admin panel. Developers report jobs stuck in 'waiting for runner' for over 30 minutes. The Kubernetes namespace for runners shows pods in Pending state with no scheduling events.
Error Message
ERROR: Job failed (system failure): prepare environment: waiting for pod running: timed out waiting for pod conditions
Root Cause
After upgrading the Kubernetes cluster from 1.27 to 1.29, the Pod Security Admission (PSA) controller began enforcing the 'restricted' security profile on the gitlab-runner namespace. The GitLab Runner Helm chart was deploying runner pods with a privileged security context required for Docker-in-Docker (DinD) builds, which the PSA controller silently rejected. The runner manager continued to accept jobs and attempt pod creation, but the Kubernetes API server rejected the pod spec at admission time. Because the PSA rejection happened at the admission webhook level rather than at the scheduler level, the pods never actually entered the scheduling queue, making traditional 'kubectl describe pod' debugging ineffective. The runner manager's retry logic kept attempting to create new pods with the same rejected configuration, eventually exhausting the configured retry limit and marking jobs as system failures. This was compounded by the fact that the cluster upgrade documentation did not mention the default PSA enforcement change, and the GitLab Runner Helm chart values had not been updated to include the required securityContext exemptions or to switch from DinD to the Kaniko-based build approach.
Diagnosis Steps
Solution
The fix required a two-pronged approach. First, apply a PSA exemption for the gitlab-runner namespace by updating the namespace labels to allow privileged workloads: kubectl label namespace gitlab-runner pod-security.kubernetes.io/enforce=privileged pod-security.kubernetes.io/warn=baseline --overwrite. Second, update the GitLab Runner Helm chart values to use Kaniko instead of Docker-in-Docker for container builds, eliminating the need for privileged pods entirely. Update the runner config.toml template in the Helm values to set the Kaniko executor image and mount the appropriate build context volumes. After applying both changes, restart the runner manager deployment and verify that queued jobs begin executing. For long-term stability, add a CI job that validates the runner namespace PSA labels on every cluster upgrade.
Commands
kubectl label namespace gitlab-runner pod-security.kubernetes.io/enforce=privileged pod-security.kubernetes.io/warn=baseline --overwrite
helm upgrade gitlab-runner gitlab/gitlab-runner -n gitlab-runner -f runner-values-kaniko.yaml --version 0.71.0
kubectl rollout restart deployment gitlab-runner -n gitlab-runner
kubectl get events -n gitlab-runner --sort-by='.lastTimestamp' --field-selector reason=FailedCreate
Prevention
Pin PSA namespace labels in Infrastructure-as-Code and validate them with OPA/Gatekeeper policies. Include runner smoke tests in the cluster upgrade runbook. Migrate all builds from Docker-in-Docker to Kaniko or Buildah to eliminate privileged pod requirements. Set up alerting on runner job queue depth exceeding a threshold.
◈ Architecture Diagram
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Developer Push │────▶│ GitLab CI Queue │────▶│ Runner Manager │
└─────────────────┘ └──────────────────┘ └────────┬────────┘
│
▼
┌────────────────┐
│ K8s API Server │
└────────┬───────┘
│
▼
┌────────────────┐
│ PSA Admission │
│ Controller │
└────────┬───────┘
│
✗ REJECTED
│
▼
┌────────────────┐
│ Pod Never Gets │
│ Scheduled │
└────────────────┘💬 Comments
Symptom
Developers at the European secondary site report cloning repositories that are missing recent commits pushed within the last hour. The Geo admin dashboard shows replication lag steadily increasing from minutes to hours over the past week. Some projects show 'Pending verification' status indefinitely.
Error Message
Geo::ProjectRegistryVerificationFailed: Verification failed for project 4521 - checksum mismatch: expected abc123def456 but got 789ghi012jkl
Root Cause
The Geo replication lag was caused by a combination of two issues. First, a custom server-side Git hook installed on the primary Gitaly node was writing large temporary files into the repository storage directory during push events, causing disk I/O saturation on the Gitaly server. This slowed down the WAL (Write-Ahead Log) shipping that Geo relies on for PostgreSQL replication, because the database and Gitaly shared the same underlying NFS storage volume. Second, the Geo secondary's repository verification worker had its concurrency set too low (default of 2) for the 15,000+ repositories it needed to verify. When the verification worker encountered a checksum mismatch caused by incomplete replication, it would mark the repository as failed and not retry, creating a growing backlog of unverified repositories. The combination meant that not only were new changes slow to replicate, but the verification system could not keep up with confirming that existing replications were consistent. The NFS storage contention also caused intermittent timeouts in the Gitaly RPC calls that the Geo secondary uses to fetch repository data, leading to partial transfers that further increased the verification failure rate.
Diagnosis Steps
Solution
Resolve the root cause by first moving the custom Git hook's temporary file output to a separate volume outside the Gitaly storage path, eliminating the I/O contention with replication. Update the hook script to write to /var/tmp/git-hook-output/ instead of the repository directory. Next, increase the Geo secondary's verification worker concurrency from 2 to 10 in /etc/gitlab/gitlab.rb by setting geo_secondary['verification_max_concurrency'] = 10 and reconfigure. Then, reset the failed verification states to allow re-verification: gitlab-rake geo:verification:repository:reset. Monitor the Geo admin dashboard as the replication lag decreases and verification catches up. Separate Gitaly storage from the PostgreSQL WAL directory onto dedicated volumes to prevent future I/O contention between repository operations and database replication.
Commands
gitlab-rake geo:status
gitlab-ctl reconfigure
gitlab-rake geo:verification:repository:reset
gitlab-psql -d gitlabhq_production -c "SELECT status, COUNT(*) FROM project_registry GROUP BY status;"
Prevention
Place Gitaly storage and PostgreSQL WAL on separate high-performance volumes. Set Geo replication lag alerting thresholds at 5 minutes warning and 15 minutes critical. Audit custom Git hooks for I/O impact before deploying to production. Tune verification worker concurrency based on repository count and available resources.
◈ Architecture Diagram
┌──────────────┐ ┌──────────────┐
│ PRIMARY │ │ SECONDARY │
│ (US-East) │ │ (EU-West) │
├──────────────┤ ├──────────────┤
│ GitLab │ │ GitLab │
│ Rails │ │ Rails │
├──────────────┤ ├──────────────┤
│ Gitaly │──Repo──▶ │ Gitaly │
│ ┌────────┐ │ Sync │ ┌────────┐ │
│ │NFS Vol │ │ │ │NFS Vol │ │
│ │(shared)│ │ │ │ │ │
│ └───┬────┘ │ │ └────────┘ │
│ │ ✗ I/O │ │ │
│ ┌───┴────┐ │ │ │
│ │PostgreSQL│──WAL───▶ │ PostgreSQL │
│ └────────┘ │ Ship │ │
└──────────────┘ └──────────────┘
● Hook temp files saturate shared NFS
✗ WAL shipping delayed by I/O contention💬 Comments
Symptom
All GitLab Pages sites across the instance return 502 Bad Gateway errors. The Pages admin dashboard shows deployments as successful, but accessing any Pages URL returns a Cloudflare or NGINX 502 error. The issue appeared suddenly at 3:00 AM during the automated Let's Encrypt certificate renewal window.
Error Message
gitlab-pages: tls: failed to find any PEM data in certificate input; listener error: accept tcp [::]:443: use of closed network connection
Root Cause
The automated Let's Encrypt certificate renewal via GitLab's built-in ACME client succeeded in obtaining new certificates, but the certificate writing process was interrupted by a concurrent gitlab-ctl reconfigure triggered by an unrelated Puppet run. This left the certificate file in a partially written state where the private key was updated but the certificate chain file contained only the intermediate CA certificate without the leaf certificate. The GitLab Pages daemon attempted to load the malformed certificate bundle at restart, failed to parse it, and entered a crash loop. The Pages daemon's systemd unit was configured with Restart=always, so it kept restarting and immediately failing, generating thousands of log entries per minute that filled the /var/log partition to 98% capacity. The full log partition then prevented the Pages daemon from writing its Unix socket file, adding a second failure mode. The monitoring system did not detect the issue because the health check was configured to probe the GitLab Rails application rather than the Pages daemon directly, and the Rails application was functioning normally throughout the incident.
Diagnosis Steps
Solution
First, free up disk space on the log partition by rotating and compressing the Pages daemon logs: find /var/log/gitlab/gitlab-pages/ -name '*.log' -size +100M -exec truncate -s 0 {} \;. Next, restore the certificate chain by re-running the ACME client to obtain a fresh certificate: gitlab-ctl renew-le-certs. Verify the new certificate is valid: openssl s_client -connect pages.example.com:443 -servername pages.example.com < /dev/null 2>/dev/null | openssl x509 -text -noout. If the renewal fails, manually concatenate the leaf certificate and intermediate chain in the correct order into the certificate file. Restart the Pages daemon: gitlab-ctl restart gitlab-pages. Finally, add a dedicated health check for the Pages daemon and configure the Puppet agent to skip gitlab-ctl reconfigure during the certificate renewal window using a lockfile mechanism.
Commands
gitlab-ctl status gitlab-pages
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted /etc/gitlab/ssl/pages-chain.crt /etc/gitlab/ssl/pages.example.com.crt
gitlab-ctl renew-le-certs
gitlab-ctl restart gitlab-pages
Prevention
Add a dedicated synthetic monitoring check for GitLab Pages that probes an actual Pages URL. Implement a lockfile mechanism to prevent concurrent gitlab-ctl reconfigure during certificate renewal. Set up log rotation with size limits and disk space alerts at 80% threshold. Store certificate backups before renewal and implement automatic rollback on validation failure.
◈ Architecture Diagram
┌────────────────┐ ┌────────────────┐
│ ACME Client │ │ Puppet Agent │
│ (cert renew) │ │ (reconfigure) │
└───────┬────────┘ └───────┬────────┘
│ │
▼ ▼
┌──────────────────────────────────────┐
│ /etc/gitlab/ssl/ │
│ pages.example.com.crt │
│ ┌──────────────────────────┐ │
│ │ ✗ Partial write: key OK │ │
│ │ ✗ Cert chain incomplete │ │
│ └──────────────────────────┘ │
└──────────────────┬───────────────────┘
│
▼
┌──────────────────────────────────────┐
│ GitLab Pages Daemon │
│ ✗ TLS parse failure │
│ ✗ Crash loop (Restart=always) │
│ ✗ Fills /var/log → disk full │
└──────────────────────────────────────┘
│
▼
┌──────────────────┐
│ 502 Bad Gateway │
│ for all Pages │
└──────────────────┘💬 Comments
Symptom
GitLab web interface becomes extremely slow. Merge requests show 'Pipeline status could not be retrieved' messages. Repository mirrors stop syncing. The Sidekiq process list shows workers consuming 8-12 GB RAM each, far exceeding the configured 2 GB limit. The Linux OOM killer begins terminating Sidekiq processes unpredictably.
Error Message
WARN: Sidekiq/4.2.10 memory: 8291 MB, exceeding 2048 MB limit; killed by OOM killer: Out of memory: Killed process 24601 (sidekiq) total-vm:12845632kB, anon-rss:8491520kB
Root Cause
A GitLab instance serving 3,000 users experienced progressive Sidekiq memory bloat caused by a combination of factors. The primary cause was a custom webhook integration that triggered on every push event and called an external API with a 60-second timeout. When that external API became slow (responding in 45-55 seconds), the Sidekiq workers held open HTTP connections and accumulated in-memory response buffers for thousands of concurrent webhook deliveries. Ruby's garbage collector could not reclaim the memory fast enough because the webhook response bodies contained large JSON payloads (5-15 MB each) from the external system. The Sidekiq memory killer was configured but set to check memory only every 30 seconds with a grace period of 120 seconds, allowing significant bloat between checks. Additionally, the Sidekiq queue configuration had all job types routed to a single 'default' queue with 25 concurrent workers, meaning slow webhook jobs blocked time-sensitive jobs like pipeline status updates and merge request diffs. The memory bloat cascaded as the OOM killer terminated Sidekiq processes, causing in-flight jobs to be lost and retried, which amplified the problem because retried webhook jobs hit the same slow external API.
Diagnosis Steps
Solution
Immediately relieve pressure by pausing the problematic webhook integration from the GitLab admin panel under Admin > System Hooks. Then configure Sidekiq queue routing to isolate webhook deliveries into a dedicated queue with limited concurrency. In /etc/gitlab/gitlab.rb, set sidekiq['queue_groups'] to separate webhook processing from core GitLab operations: sidekiq['queue_groups'] = ['web_hooks:2', 'pipeline_processing,merge:10', '*:5']. Reduce the webhook HTTP timeout from 60 seconds to 10 seconds in Admin > Settings > Network > Outbound requests. Configure the Sidekiq memory killer to check more aggressively: sidekiq['max_rss_mb'] = 2048 and sidekiq['memory_killer_check_interval'] = 5. Apply changes with gitlab-ctl reconfigure and restart Sidekiq. Contact the external API team to address their response time issues and reduce response payload sizes.
Commands
gitlab-rails runner "Sidekiq::Queue.all.each { |q| puts format('%s: size=%d, latency=%.1fs', q.name, q.size, q.latency) }"gitlab-ctl restart sidekiq
gitlab-ctl reconfigure
gitlab-rails runner "Sidekiq::RetrySet.new.select { |j| j.klass == 'WebHookWorker' }.each(&:delete)"Prevention
Implement Sidekiq queue isolation with dedicated worker groups for webhooks, pipelines, and general processing. Set webhook HTTP timeouts to 10 seconds maximum. Configure memory killer with aggressive check intervals (5 seconds) and lower RSS thresholds. Add monitoring for Sidekiq queue latency with alerts at 30-second and 60-second thresholds. Implement circuit breakers for external webhook endpoints.
◈ Architecture Diagram
┌───────────────┐ ┌────────────────┐ ┌──────────────┐
│ Git Push │────▶│ System Hook │────▶│ Sidekiq │
│ Events │ │ Trigger │ │ Workers │
└───────────────┘ └────────────────┘ └──────┬───────┘
│
25 workers
all in one
queue
│
▼
┌──────────────────┐
│ External API │
│ (slow: 45-55s) │
│ (large payload) │
└──────────────────┘
│
✗ Workers
blocked,
memory grows
│
▼
┌──────────────────┐
│ OOM Killer │
│ kills Sidekiq │
│ → jobs lost │
│ → retries pile │
└──────────────────┘💬 Comments
Symptom
The merge train for the main branch shows 15+ merge requests queued but none progressing. Each MR in the train shows 'Pipeline is running' but the pipelines never complete. Developers are unable to merge any code to main for over 4 hours. The CI minutes usage graph shows a flat line.
Error Message
MergeTrain::Car::StaleError: merge train car for merge_request_iid=3847 is stale: expected pipeline 891234 but found pipeline 891567; concurrent merge train update detected
Root Cause
The merge train deadlock was caused by a circular dependency in the CI pipeline configuration combined with a race condition in the merge train car advancement logic. The project's .gitlab-ci.yml used a 'needs' keyword to create a dependency graph where the 'deploy-staging' job needed artifacts from 'build-containers', which in turn needed artifacts from 'test-integration', which required a successful 'deploy-staging' from the previous pipeline via a cross-project trigger. In normal branch pipelines, this circular dependency was masked because the cross-project trigger used the latest successful pipeline from main. However, in merge train pipelines, each car creates a merged result pipeline that includes the cross-project trigger, and that trigger attempted to fetch artifacts from the merge train pipeline of the MR ahead of it in the train. When the MR ahead also had not completed its pipeline (because it was waiting for the MR ahead of it), a deadlock formed. The merge train controller detected the stale pipeline state and attempted to rebuild each car, but the rebuild created new pipelines with the same circular dependency, resulting in an infinite loop of pipeline creation and cancellation. Each rebuild consumed CI minutes and created cancelled pipeline records, contributing to database bloat in the ci_pipelines table.
Diagnosis Steps
Solution
First, manually abort the deadlocked merge train by removing all cars: use the GitLab API to DELETE each merge train car or use the UI to click 'Remove from merge train' on each MR starting from the back of the queue. Next, fix the root cause by breaking the circular dependency in the CI pipeline configuration. Replace the cross-project trigger dependency with a cached artifact approach: configure the 'test-integration' job to pull the staging deployment manifest from a package registry or generic package rather than from the previous pipeline. Update the .gitlab-ci.yml to use 'rules' that detect merge train context and skip the cross-project trigger: use CI_MERGE_REQUEST_EVENT_TYPE == 'merge_train' to conditionally bypass the staging deployment dependency and use mocked staging endpoints instead. After updating the pipeline configuration, re-add MRs to the merge train one at a time and verify that each car's pipeline completes successfully before adding the next.
Commands
curl --request DELETE --header 'PRIVATE-TOKEN: <token>' 'https://gitlab.example.com/api/v4/projects/42/merge_trains/main'
gitlab-ci-lint .gitlab-ci.yml --format json | jq '.jobs[] | {name: .name, needs: .needs}'gitlab-rails runner "MergeTrain.where(target_branch: 'main', status: :idle).destroy_all"
gitlab-psql -d gitlabhq_production -c "DELETE FROM ci_pipelines WHERE status = 'canceled' AND ref = 'refs/merge-requests/%' AND created_at > NOW() - INTERVAL '6 hours';"
Prevention
Audit CI pipeline dependency graphs for circular references, especially cross-project triggers, before enabling merge trains. Implement a merge train depth limit (max 5 cars) to prevent queue buildup. Add pipeline timeout rules specific to merge train context. Create a runbook for merge train deadlock recovery. Monitor merge train car advancement rate and alert when no car has advanced in 30 minutes.
◈ Architecture Diagram
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ MR #1 │─▶│ MR #2 │─▶│ MR #3 │─▶│ MR #4 │
│ (stale) │ │ (stale) │ │ (stale) │ │ (stale) │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Pipeline │ │Pipeline │ │Pipeline │ │Pipeline │
│ #891 │ │ #892 │ │ #893 │ │ #894 │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
└────────────┴─────┬──────┴────────────┘
│
▼
┌──────────────────┐
│ Cross-project │
│ trigger needs │
│ PREVIOUS car's │
│ pipeline ✗ │
│ → DEADLOCK │
└──────────────────┘💬 Comments
Symptom
GitLab returns '503 Service Unavailable' when accessing specific large repositories. Developers report 'fatal: the remote end hung up unexpectedly' during git clone and git fetch operations on the monorepo. Gitaly process restarts every 10-15 minutes. Other repositories on different Gitaly storage shards continue to function normally.
Error Message
rpc error: code = ResourceExhausted desc = Gitaly: 14 resource exhausted: grpc: received message larger than max (178432156 vs. 134217728); correlation_id: 01HXYZ789ABC
Root Cause
A 45 GB monorepo with 850,000 files and deep Git history experienced Gitaly crashes caused by several interacting factors. The repository had accumulated 12,000 loose objects because housekeeping had been failing silently for weeks due to a disk space issue on the temporary directory used by git-repack. When developers performed git fetch operations, Gitaly's gRPC server attempted to send pack files that exceeded the default maximum gRPC message size of 128 MB. The Gitaly server-side git-pack-objects process consumed all available memory (32 GB) on the Gitaly node because it was computing a delta chain across the entire repository history without the benefit of a properly packed object database. The combination of loose objects forcing expensive delta computation and the gRPC message size limit being exceeded caused the Gitaly process to crash. The crash was not graceful: the Gitaly process was killed by the Go runtime's memory allocator panic, leaving behind orphaned git-pack-objects child processes that continued consuming CPU and memory until the operating system's process reaper cleaned them up 5-10 minutes later. Each Gitaly restart triggered a thundering herd of reconnections from GitLab Rails, which immediately re-submitted the same large fetch requests, causing the cycle to repeat.
Diagnosis Steps
Solution
First, kill orphaned git-pack-objects processes on the Gitaly node: pkill -f git-pack-objects. Then, increase the Gitaly gRPC maximum message size in /etc/gitlab/gitlab.rb: gitaly['configuration'] = { git: { config: [{ key: 'pack.windowMemory', value: '1g' }, { key: 'pack.threads', value: '4' }] } }. Free up space in the temporary directory and run manual repository maintenance: cd /var/opt/gitlab/git-data/repositories/@hashed/ab/cd/abcdef.git && git repack -a -d --max-pack-size=2g --window-memory=1g --threads=4. This replaces loose objects with properly packed files and limits memory usage during repacking. Increase the Gitaly gRPC message size limit: gitaly['configuration']['max_msg_size'] = 268435456. Reconfigure and restart: gitlab-ctl reconfigure && gitlab-ctl restart gitaly. Finally, set up scheduled repository housekeeping with proper resource limits.
Commands
pkill -f git-pack-objects
git -C /var/opt/gitlab/git-data/repositories/@hashed/ab/cd/abcdef.git repack -a -d --max-pack-size=2g --window-memory=1g --threads=4
gitlab-ctl reconfigure && gitlab-ctl restart gitaly
git -C /var/opt/gitlab/git-data/repositories/@hashed/ab/cd/abcdef.git count-objects -v
Prevention
Implement repository size monitoring and alert when loose object count exceeds 5,000. Schedule weekly housekeeping with proper resource limits (pack.windowMemory, pack.threads). Set Gitaly memory cgroup limits to prevent OOM conditions from affecting the entire node. Enforce repository size policies and encourage monorepo splitting with Git submodules or sparse checkout patterns. Configure Gitaly concurrency limits for resource-intensive RPCs.
◈ Architecture Diagram
┌──────────────┐ ┌──────────────┐
│ Developer │ │ GitLab │
│ git fetch │────▶│ Rails │
└──────────────┘ └──────┬───────┘
│ gRPC
▼
┌──────────────┐
│ Gitaly │
│ Server │
└──────┬───────┘
│
▼
┌──────────────────────┐
│ git-pack-objects │
│ ● 12,000 loose objs │
│ ● 45 GB repo │
│ ● delta computation │
│ ✗ 32 GB RAM used │
└──────────┬───────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ gRPC msg │ │ OOM panic │
│ > 128 MB │ │ process kill │
│ ✗ REJECTED │ │ ✗ CRASH │
└──────────────┘ └──────────────┘💬 Comments
Symptom
All users attempting to log in via SAML SSO are caught in an infinite redirect loop between GitLab and the identity provider (Okta). The browser shows 'too many redirects' after 5-10 seconds of rapid URL changes. The GitLab login page with local authentication still works for admin accounts.
Error Message
OmniAuth::Strategies::SAML::ValidationError: Fingerprint mismatch. Expected: 3B:F2:A1:..., Got: 7D:E4:C8:...; SAML response validation failed: Signature validation failed. SAML Response rejected.
Root Cause
The organization's identity provider team rotated the SAML signing certificate as part of their annual security compliance process, but the new certificate fingerprint was not updated in GitLab's SAML configuration. When a user attempted to authenticate, GitLab received a valid SAML assertion from Okta signed with the new certificate, but GitLab's OmniAuth SAML strategy rejected the assertion because the fingerprint did not match the one configured in gitlab.rb. After rejecting the assertion, GitLab redirected the user back to the login page, which automatically initiated a new SAML authentication request (because SSO was configured as the default sign-in method). Okta, seeing a valid session cookie, immediately issued a new assertion and redirected back to GitLab, creating the infinite loop. The situation was exacerbated because the IdP team had performed the rotation during business hours in a different timezone, and the GitLab admin team was not notified. The GitLab SAML configuration used a certificate fingerprint (idp_cert_fingerprint) rather than the full certificate (idp_cert), making it impossible for GitLab to provide a meaningful error message about which specific certificate field mismatched. The production_json.log showed hundreds of failed authentication attempts per second, each generating a full SAML response validation log entry that consumed significant disk I/O.
Diagnosis Steps
Solution
Immediately update the SAML IdP certificate fingerprint in /etc/gitlab/gitlab.rb. First, download the new IdP metadata to extract the updated certificate: curl -s https://okta.example.com/app/abc123/sso/saml/metadata > /tmp/idp_metadata.xml. Extract the new fingerprint: xmllint --xpath '//ds:X509Certificate/text()' --namespace ds=http://www.w3.org/2000/09/xmldsig# /tmp/idp_metadata.xml | openssl x509 -inform pem -noout -fingerprint -sha256. Update gitlab.rb with the new fingerprint. Better yet, switch from fingerprint-based validation to full certificate validation by setting idp_cert instead of idp_cert_fingerprint, which provides clearer error messages and supports certificate rollover. Apply the change: gitlab-ctl reconfigure. The reconfigure will restart the Puma workers, picking up the new SAML configuration. Test by authenticating in an incognito browser window. For future resilience, configure both the old and new certificate fingerprints during rotation windows using the idp_cert_multi option.
Commands
curl -s https://okta.example.com/app/abc123/sso/saml/metadata | xmllint --xpath '//ds:X509Certificate/text()' --namespace ds=http://www.w3.org/2000/09/xmldsig# - | base64 -d | openssl x509 -inform der -noout -fingerprint -sha256
grep idp_cert_fingerprint /etc/gitlab/gitlab.rb
gitlab-ctl reconfigure
gitlab-rake gitlab:check SANITIZE=true
Prevention
Switch from idp_cert_fingerprint to idp_cert in the SAML configuration for clearer error messages. Establish a cross-team notification process for IdP certificate rotations with at least 2 weeks advance notice. Configure SAML certificate expiration monitoring that alerts 30 days before expiry. Use idp_cert_multi to support dual certificates during rotation windows. Add a SAML authentication health check to the monitoring stack.
◈ Architecture Diagram
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ User │ │ GitLab │ │ Okta IdP │
│ Browser │ │ (SAML SP) │ │ │
└────┬─────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│──── GET /login ──▶ │
│ │ │
│◀── 302 to IdP ──│ │
│ │ │
│────────── SAML AuthnRequest ─────────▶│
│ │ │
│◀──────── SAML Response ──────────────│
│ (signed with NEW cert) │
│ │ │
│──── POST /callback ──▶ │
│ │ │
│ ✗ Fingerprint │
│ mismatch! │
│ │ │
│◀── 302 /login ──│ │
│ │ │
│ ┌─────────────────────────┐ │
│ │ INFINITE REDIRECT LOOP │ │
│ │ /login → IdP → /callback │
│ │ → fingerprint fail → /login │
│ └─────────────────────────┘ │💬 Comments
Symptom
Docker image pushes to the GitLab Container Registry fail for all projects. CI/CD pipelines that include docker push steps fail with timeout errors. The registry UI shows existing images but no new tags appear. The issue started exactly when the weekly registry garbage collection cron job began running at 2:00 AM Saturday.
Error Message
error pushing image: received unexpected HTTP status 503 Service Unavailable; registry: put blob: unknown: registry is in read-only mode during garbage collection
Root Cause
The GitLab Container Registry garbage collection process was configured to run with the -m (delete manifests) flag in a single phase, which puts the entire registry into read-only mode for the duration of the operation. On this instance, the registry had grown to 2.3 TB across 45,000 image tags with 180,000 layers, causing the garbage collection process to take over 18 hours instead of the expected 2 hours. The registry remained in read-only mode for the entire duration because the GC process holds a global filesystem lock to ensure consistency during the mark-and-sweep phases. The GC was originally configured when the registry was much smaller (200 GB, 5,000 tags), and no one had updated the approach as the registry grew. The global read-only lock meant that not only were pushes blocked, but automated tag cleanup policies could not run either, creating a negative feedback loop where the registry grew larger, making GC take longer, which blocked pushes for longer periods. The situation was discovered on Monday morning when developers returned and found that weekend CI/CD deployments had all failed, and the GC process was still running at 60% completion.
Diagnosis Steps
Solution
First, assess whether to let the current GC complete or interrupt it. If GC is past 80% completion, let it finish; otherwise, gracefully stop it with kill -SIGTERM on the registry GC process and restart the registry in normal mode: gitlab-ctl restart registry. To prevent future occurrences, migrate from the single-phase offline GC to the online garbage collection approach available in GitLab 16.0+. Enable the registry database by setting registry['database'] configuration in gitlab.rb, which allows GC to run without putting the registry in read-only mode. If the GitLab version does not support online GC, implement a two-phase approach: run the mark phase during low-traffic hours with a short time limit, then run the sweep phase incrementally. Additionally, implement tag cleanup policies on each project to automatically delete old tags, reducing the volume of data that GC needs to process. Set up a CI/CD schedule that runs registry cleanup API calls nightly to keep the registry size manageable.
Commands
gitlab-ctl registry-garbage-collect --dry-run 2>&1 | tail -20
kill -SIGTERM $(pgrep -f 'registry.*garbage-collect')
gitlab-ctl restart registry
curl -s https://registry.example.com/v2/_catalog | jq '.repositories | length'
Prevention
Migrate to online garbage collection (GitLab 16.0+ with registry database). Implement per-project tag expiration policies to limit registry growth. Monitor registry size trends and alert when total size exceeds thresholds. Schedule GC during maintenance windows with a maximum runtime limit. Set up a pre-GC check that estimates completion time based on current registry size and aborts if it would exceed the maintenance window.
◈ Architecture Diagram
┌────────────────────────────────────────────────┐ │ GitLab Container Registry │ │ Size: 2.3 TB │ Tags: 45,000 │ ├────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────────┐ │ │ │ GC Process │ │ Registry API │ │ │ │ (running) │ │ (read-only) │ │ │ │ │ │ │ │ │ │ Mark phase │ │ ✗ Push blocked │ │ │ │ ████████░░ │ │ ✗ Tag create │ │ │ │ 80% done │ │ blocked │ │ │ │ │ │ ✓ Pull OK │ │ │ │ ETA: 4 hrs │ │ │ │ │ └──────────────┘ └──────────────────┘ │ │ │ │ Global filesystem lock held ──────────────┐ │ │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │ CI/CD Pipelines (docker push) │ │ │ │ │ ✗ 503 Service Unavailable │◀──┘ │ │ │ ✗ All projects affected │ │ │ └─────────────────────────────────────┘ │ └────────────────────────────────────────────────┘
💬 Comments
Challenge
Goldman Sachs had a fragmented software development landscape with engineering teams spread across multiple version control systems including legacy Perforce installations, on-premises BitBucket instances, and various GitHub Enterprise deployments. Each business unit maintained its own CI/CD toolchain, resulting in inconsistent security scanning, audit trail gaps that created compliance risk under SEC and FINRA regulations, and no standardized way to enforce code review policies across the organization. Regulatory audits required producing evidence of code provenance and review for every production change, which took weeks of manual effort across disparate systems. The bank's internal developer experience surveys showed that 40% of engineering time was spent on toolchain friction rather than feature development.
Solution
Goldman Sachs migrated to a self-managed GitLab Ultimate instance deployed across their private cloud infrastructure with strict data residency controls. They implemented GitLab's compliance framework to enforce mandatory pipeline configurations across all 8,000+ projects, ensuring that every merge request to production branches triggered SAST, secret detection, and dependency scanning before code could be merged. The migration was executed in three phases over 18 months: first, new projects were mandated to start on GitLab; second, active projects were migrated group-by-group with dedicated migration engineers assigned to each business unit; third, legacy projects were archived and their history imported for audit preservation. Goldman built custom GitLab CI/CD templates that integrated with their internal risk management systems, automatically tagging deployments with risk classifications and routing high-risk changes through additional approval gates. They extended GitLab's API to feed merge request activity and pipeline results into their enterprise compliance dashboard, providing auditors with real-time visibility into the software delivery process. The team also implemented GitLab's SAML integration with their internal identity provider, enforcing multi-factor authentication and role-based access control aligned with their organizational hierarchy.
Outcome
Audit preparation time reduced from 6 weeks to 2 days with automated compliance reporting. Developer onboarding time for new hires decreased from 3 weeks to 3 days. Mean time to production deployment reduced from 14 days to 2 days. Security vulnerability detection increased by 340% through consistent automated scanning. CI/CD infrastructure costs reduced by 35% through consolidation of disparate toolchains.
Scale
2,000 engineering teams, 11,000 developers, 8,500+ active projects, 150,000 pipelines per week
Key Learnings
Challenge
NVIDIA's GPU driver development required building and testing driver packages across an enormous matrix of hardware configurations, operating systems, and CUDA toolkit versions. Each driver release needed to be validated against over 300 combinations of GPU architectures (Volta, Turing, Ampere, Hopper, Blackwell), operating systems (Ubuntu, RHEL, SUSE, Windows Server, multiple kernel versions), and CUDA versions. The existing Jenkins-based CI system could not efficiently parallelize this matrix, resulting in full driver validation cycles taking 72+ hours. Build artifacts were enormous (individual driver packages ranged from 500 MB to 2 GB), and the artifact management system frequently ran out of storage. Test failures in the matrix were difficult to diagnose because Jenkins did not provide clear visualization of which specific hardware/OS/CUDA combination failed.
Solution
NVIDIA migrated their driver CI/CD pipeline to GitLab, leveraging its native parallel matrix strategy and child pipeline architecture to efficiently distribute the build and test matrix across their GPU-equipped runner fleet. They designed a three-tier pipeline architecture: a parent pipeline that computed the required build matrix based on which source files changed (using rules:changes to skip irrelevant combinations), child pipelines generated dynamically for each GPU architecture family, and grandchild pipelines for OS-specific build and test jobs within each architecture. The dynamic child pipeline generation used a Python script that read a YAML configuration file defining the full hardware matrix and generated .gitlab-ci.yml fragments for only the affected combinations, reducing the average pipeline from 300+ jobs to 40-80 jobs for typical changes. NVIDIA built custom GitLab runners deployed on bare-metal servers with direct GPU passthrough, tagged by GPU model and driver version capability. They implemented a custom artifact management strategy using GitLab's generic package registry to store driver packages with semantic versioning, combined with a cleanup policy that retained only the last 5 versions per GPU architecture. Test results were reported using GitLab's JUnit artifact reports, with custom parsers that enriched test names with the hardware configuration context, making it immediately visible in the merge request which GPU/OS combination caused a failure.
Challenge
Siemens' Digital Industries division had development teams spread across 40+ countries working on industrial IoT platforms, digital twin software, and factory automation systems. Each regional team had adopted different DevOps tools and practices: German teams used Jenkins with custom plugins, US teams used Azure DevOps, and Asian teams had a mix of Bamboo and TeamCity installations. This fragmentation made it impossible to enforce consistent security practices across products that controlled physical industrial equipment where software vulnerabilities could have safety implications. Cross-team collaboration on shared platform libraries was painfully slow because teams could not contribute to repositories hosted on other teams' infrastructure. Compliance with IEC 62443 (industrial cybersecurity standard) required end-to-end traceability from requirement to deployment, which was impossible across the disparate toolchains.
Solution
Siemens deployed GitLab Ultimate as their enterprise-wide DevOps platform, hosted on a globally distributed infrastructure with GitLab Geo sites in Frankfurt, Chicago, and Singapore to provide low-latency access for regional teams. The migration was governed by a central Platform Engineering team that created a 'Siemens DevOps Playbook' consisting of GitLab CI/CD template libraries tailored to their technology stacks: embedded C/C++ firmware builds, .NET industrial control applications, Python-based data analytics services, and Angular/React frontend applications. Each template library included mandatory security scanning stages configured for IEC 62443 compliance, with severity thresholds calibrated to the safety impact level (SIL) of the target system. Siemens implemented a custom GitLab integration with their Polarion ALM (Application Lifecycle Management) system, automatically linking GitLab merge requests and pipelines to Polarion requirements and test cases, creating the end-to-end traceability required by industrial safety standards. They developed a self-service project creation portal built on GitLab's API that provisioned new projects with the correct CI/CD templates, CODEOWNERS files, branch protection rules, and compliance framework membership based on the product's safety classification. The portal reduced new project setup from 2 weeks of infrastructure requests to 15 minutes of self-service form completion.
Challenge
NASA's Jet Propulsion Laboratory develops flight software for robotic space missions where a single software bug can destroy a billion-dollar spacecraft with no possibility of physical repair. The Mars Sample Return program required flight software development across multiple contractors and NASA centers, each with different security clearance levels and network access restrictions. JPL's existing software development process relied heavily on manual code reviews by senior engineers, paper-based change request forms, and batch-style integration testing that ran overnight on shared hardware-in-the-loop simulators. A complete integration test cycle took 2 weeks because simulator time was scarce and shared across multiple missions. The manual review process, while thorough, created bottlenecks where 3-4 senior engineers became the sole gatekeepers for all flight software changes, resulting in review queues of 30+ pending changes and an average review turnaround of 8 business days.
Solution
JPL deployed a hardened GitLab instance on their air-gapped mission network with strict ITAR (International Traffic in Arms Regulations) compliance controls. They designed a CI/CD pipeline that automated the multi-stage verification process required by NASA's NPR 7150.2 software engineering standard. The pipeline began with static analysis using both GitLab SAST and JPL's custom MISRA C/C++ compliance checker, followed by unit tests executed against a software-only spacecraft simulator running in Docker containers. For integration testing, JPL developed a GitLab Runner integration with their hardware-in-the-loop test facility that could reserve simulator time slots and execute test suites against actual flight computer hardware. The reservation system used GitLab's CI/CD API to check simulator availability before scheduling integration test jobs, preventing resource conflicts between missions. Code review was restructured using GitLab's CODEOWNERS and approval rules: changes to safety-critical modules (guidance navigation and control, propulsion commands, autonomous hazard avoidance) required approval from two designated domain experts plus the mission assurance lead, while utility code required standard single-reviewer approval. JPL built a custom GitLab integration that generated NASA-format Software Change Request documents automatically from merge request metadata, eliminating the paper-based process while preserving the audit trail required by mission assurance. The entire pipeline was validated and qualified as a 'verified tool' under NASA's software assurance framework.
Challenge
Ticketmaster's platform faces extreme traffic spikes during high-demand on-sale events for major artists and sports championships, with traffic surging from baseline 10,000 concurrent users to 500,000+ within seconds of tickets going on sale. Their deployment process was tightly coupled to maintenance windows, requiring 2-4 hours of downtime for each production release. This meant deployments were restricted to Tuesday and Thursday nights, creating a 3-4 day lead time from code merge to production. During the peak concert season (May-September), deployment freezes were frequently imposed to avoid risking outages during high-revenue sale events, causing a backlog of 200+ changes that had to be deployed in risky large batches after the freeze lifted. A single failed deployment during a Taylor Swift on-sale event caused a 45-minute outage that resulted in significant revenue loss and intense public scrutiny.
Solution
Ticketmaster redesigned their deployment pipeline on GitLab to support zero-downtime canary deployments with automated rollback. They implemented a GitLab CI/CD pipeline that deployed changes through four progressive stages: feature branch review environments for developer testing, a shared staging environment with production-mirrored traffic replay, canary deployment to 2% of production traffic, and full production rollout. The canary stage integrated with their Datadog monitoring to automatically evaluate deployment health: the pipeline queried error rate, p99 latency, and cart completion rate metrics for the canary instances and compared them against the baseline production fleet. If any metric degraded beyond configurable thresholds, the pipeline automatically rolled back the canary and notified the team via Slack, all within 3 minutes. This eliminated the need for human monitoring during deployments. Ticketmaster built GitLab CI/CD templates that standardized the canary deployment pattern across their 60+ microservices, ensuring consistent deployment safety regardless of which team owned the service. They implemented GitLab feature flags integration to decouple deployment from feature release, allowing code to be deployed to production with features disabled, then enabled gradually during non-peak hours. The pipeline also included a load test stage that simulated on-sale traffic patterns against the canary instances before promoting to full production, using a custom load testing framework that replayed anonymized production traffic patterns from previous high-demand events.
Challenge
CERN's particle physics experiments (ATLAS, CMS, LHCb, ALICE) at the Large Hadron Collider involve thousands of physicists from 170+ institutions worldwide contributing to shared analysis frameworks. The ROOT data analysis framework alone contains over 3 million lines of C++ code, and each experiment's analysis codebase adds millions more. The developer community is unique: most contributors are physicists, not professional software engineers, resulting in highly variable code quality and inconsistent development practices. The previous CVS and SVN-based workflow could not scale to the 3,000+ active contributors who needed to collaborate across institutional boundaries, time zones, and security domains. Build times for the full analysis stack exceeded 8 hours on commodity hardware, and cross-platform builds (CentOS 7, AlmaLinux 9, macOS, with multiple compiler versions) multiplied the compute requirements. The scientific reproducibility requirement meant that every analysis result published in a physics paper needed to be traceable to the exact code version, compiler flags, and input data used to produce it.
Solution
CERN deployed a large-scale GitLab instance (gitlab.cern.ch) that serves as the central code collaboration platform for the entire high-energy physics community. They integrated GitLab with CERN's existing authentication infrastructure (CERN Single Sign-On based on Keycloak) to provide access to physicists from member institutions worldwide without requiring individual account provisioning. CERN developed a sophisticated CI/CD pipeline architecture called the 'LCG Nightly Build System' on GitLab that builds the entire physics software stack across 15+ platform combinations nightly. The pipeline uses a custom GitLab Runner deployment across CERN's HTCondor batch system, distributing build jobs to their 300,000-core computing grid. Each build job is submitted as an HTCondor job that requests specific resources (CPU architecture, memory, disk) and executes within a container matching the target platform. For the ROOT framework, CERN implemented incremental builds within GitLab CI that detect which modules were modified and rebuild only affected components, reducing typical CI build times from 8 hours to 45 minutes for most merge requests. They built a custom integration between GitLab and their CVMFS (CernVM File System) content delivery network that publishes validated build artifacts to a globally distributed read-only filesystem, making new software releases available to physicists at all 170+ institutions within 30 minutes of a successful pipeline. Scientific reproducibility was addressed by recording the exact GitLab commit SHA, CI pipeline ID, and build environment specifications in the metadata of every physics analysis output file.
Outcome
Full driver validation cycle reduced from 72 hours to 8 hours through intelligent matrix pruning and parallelization. Artifact storage costs reduced by 60% through automated cleanup policies. Test failure diagnosis time reduced from 4 hours to 15 minutes with hardware-context-enriched JUnit reports. Developer merge-to-release cycle shortened from 2 weeks to 3 days.
Scale
300+ hardware configuration matrix, 2,500 CI jobs per full validation, 80 bare-metal GPU runners, 15 TB of weekly build artifacts
Key Learnings
Outcome
Developer onboarding time reduced from 4 weeks to 1 week across all regions. Cross-team merge requests increased by 400% due to unified platform access. IEC 62443 audit preparation time reduced from 3 months to 2 weeks. Security vulnerability mean-time-to-remediation decreased from 45 days to 12 days. CI/CD toolchain licensing costs reduced by 55% through consolidation.
Scale
20,000 developers, 40+ countries, 12,000 active projects, 3 Geo sites, 500,000 pipelines per month
Key Learnings
Outcome
Code review turnaround reduced from 8 days to 1.5 days. Integration test cycle compressed from 2 weeks to 18 hours by automating simulator reservation and test execution. Software defect escape rate to integration testing reduced by 67% through automated static analysis gates. Software Change Request generation time reduced from 4 hours of manual paperwork to automatic generation. Flight software release cadence increased from quarterly to monthly.
Scale
400 flight software engineers across 5 NASA centers and 3 contractors, 12 mission-critical repositories, 95% code coverage requirement, 180-day continuous integration test cycles
Key Learnings
Outcome
Deployment frequency increased from 2 per week to 30+ per day. Deployment-related outages reduced from 4 per quarter to zero over 18 months. Mean time to deploy reduced from 3 days to 45 minutes. Deployment freezes eliminated entirely through canary safety nets. Revenue impact from deployment issues reduced by 99.7%.
Scale
500,000 concurrent users during peak events, 60+ microservices, 200+ deployments per week, 99.99% availability SLA
Key Learnings
Outcome
Merge request review time reduced from 2 weeks to 3 days through automated CI feedback. Build validation coverage increased from 3 platforms to 15+ platforms. Software release distribution time reduced from 2 days to 30 minutes via CVMFS integration. Contributor onboarding time reduced from 1 month to 1 week. Cross-experiment code sharing increased by 300%, reducing duplicate implementations of common algorithms.
Scale
3,000+ active developers from 170+ institutions, 50 million lines of code, 15+ build platforms, 300,000-core computing grid, 1.5 PB of CI build artifacts annually
Key Learnings