Skip to content
AstroPaper
Go back

GitLab CI/CD — Complete Deep-Dive Engineering Guide

Updated:
Edit page

GitLab CI/CD — Complete Deep-Dive Engineering Guide

For a frontend engineer (React / Next.js / Astro / TypeScript) moving toward DevOps, CI/CD, release engineering, automation, and platform engineering.


Table of Contents

Open Table of Contents

1. Big Picture

1.1 What GitLab CI/CD Is

GitLab CI/CD is an automation engine built into the GitLab platform. It reads a .gitlab-ci.yml file at the root of your repository and executes pipelines — ordered sequences of jobs — in response to Git events such as pushes, merge requests, tags, and schedules.

Unlike GitHub Actions where automation is one feature among many, GitLab CI/CD is deeply integrated into the entire GitLab product — issues, merge requests, container registry, package registry, environments, and monitoring are all first-class citizens in the same platform. This means your pipeline doesn’t just run tests; it can create releases, push container images to GitLab’s built-in registry, manage environments, and report directly on merge requests — all without third-party integrations.

The React/Next.js analogy: If GitHub Actions is like assembling your build toolchain from npm packages (Webpack, Babel, ESLint, etc.), GitLab CI/CD is more like Next.js — an opinionated, batteries-included framework where routing, SSR, API routes, and optimization are all built in. You trade some flexibility for cohesion.

1.2 Core Concepts

GitLab Repository

The Git repository hosted on GitLab. It contains your source code and the .gitlab-ci.yml pipeline definition. GitLab reads this file from the root of the default branch (or the branch being pushed) to determine what to run.

GitLab Runner

The agent that executes your jobs. Runners can be:

Analogy: Runners are like CI servers. In GitHub Actions terms, a runner is equivalent to the runs-on machine. The difference is that GitLab runners are a separate, installable component — you can host them yourself on any machine, VM, or Kubernetes cluster.

Pipeline

A complete execution of your .gitlab-ci.yml for a specific commit. A pipeline contains stages, and stages contain jobs.

Analogy: A pipeline is like a full npm run build cycle — but orchestrated across multiple machines with stages and parallelism.

Stage

A logical grouping of jobs that execute in sequence. All jobs in the same stage run in parallel. Stages execute one after another — the next stage only starts when all jobs in the previous stage succeed.

Analogy: Stages are like the phases of a build: first lint, then test, then build, then deploy. You wouldn’t deploy before tests pass.

Job

The fundamental unit of work. A job runs on a single runner, executes scripts, and can produce artifacts, interact with caches, and report status.

Analogy: A job is like one script in package.jsonnpm run lint or npm run test. Each job runs independently on its own runner.

Artifact

A file or directory produced by a job that needs to be passed to later jobs or downloaded by a user. Artifacts are attached to the pipeline and available for a configurable retention period.

Analogy: Like the dist/ folder from npm run build — the output you want to keep and use later.

Key distinction from cache: Artifacts are job outputs intended for downstream consumption. Caches are speed optimizations for repeated installs.

Cache

Stored data (typically node_modules or package manager caches) that persists across pipeline runs to speed up jobs. Caches are best-effort — they may not be available, and your job must work without them.

Analogy: Like .next/cache or the npm global cache — speeds things up but is not required for correctness.

Variable

A key-value pair available to jobs as environment variables. Variables can be:

Analogy: Like environment variables in Vercel or .env files — but with scope, protection, and masking controls.

Environment

A named deployment target (e.g., staging, production) tracked by GitLab. Environments have deployment history, rollback capability, and can be linked to external URLs.

Analogy: Like Vercel’s Preview vs. Production, but you define and control the rules yourself.

Deployment

The act of releasing your application to an environment. GitLab tracks deployments per environment, providing a history and the ability to roll back.

1.3 The Lifecycle: git push → deployment

┌─────────────────────────────────────────────────────────────────┐
│ DEVELOPER                                                       │
│                                                                 │
│  git add . → git commit → git push origin feature/login        │
│                                                                 │
└──────────────────────────┬──────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ GITLAB                                                          │
│                                                                 │
│  Receives push → reads .gitlab-ci.yml → creates pipeline       │
│                                                                 │
└──────────────────────────┬──────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ PIPELINE                                                        │
│                                                                 │
│  Stage: lint            Stage: test           Stage: build      │
│  ┌──────────┐           ┌──────────┐         ┌──────────┐     │
│  │ lint:js  │           │ test:unit│         │  build   │     │
│  └──────────┘           │ test:e2e │         └────┬─────┘     │
│       │                 └──────────┘              │           │
│       │                      │                    │           │
│       └──────────────────────┼────────────────────┘           │
│                              ▼                                 │
│                      Stage: deploy                             │
│                      ┌────────────┐                            │
│                      │  deploy    │  → environment: production │
│                      └────────────┘                            │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ DEPLOYMENT TARGET                                               │
│                                                                 │
│  Vercel / Cloudflare / AWS / Docker registry / Kubernetes      │
│  Status reported back to GitLab merge request                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

1.4 Comparison With Other CI/CD Systems

DimensionGitLab CI/CDGitHub ActionsJenkinsCircleCIAzure DevOps
Config.gitlab-ci.ymlYAML per workflowJenkinsfile (Groovy).circleci/config.ymlazure-pipelines.yml
Pipeline modelStage-based (sequential stages, parallel jobs within)Job-based DAG with needsPipeline/stage/stepWorkflow → jobs → stepsStage → job → step
Runner modelSeparate installable runner (gitlab-runner)Built into platform or self-hostedAlways self-hostedCloud or self-hostedCloud or self-hosted
Container registryBuilt-inGitHub Packages (GHCR)Plugin-basedNone built-inAzure Container Registry
EnvironmentsFirst-class with deployment history and rollbackEnvironments with protection rulesPlugin-basedNone built-inEnvironments with approvals
Marketplace/reuseCI/CD templates, include20,000+ actions marketplace1,800+ pluginsOrbsExtensions
Merge request integrationDeep — CI status, test reports, security scans, code quality inlinePR checks and statusVia webhooks/pluginsPR checksPR policies
Best forGitLab-centric teams wanting all-in-oneGitHub-centric teamsCustom/legacy infraFast CI for product teamsMicrosoft-centric enterprises
Biggest weaknessYAML complexity, less community reuse than ActionsLess integrated platformMaintenance burdenSeparate platformHeavier UX

1.5 When GitLab CI/CD Is a Better Choice

Choose GitLab CI/CD when:

Choose something else when:

1.6 Mental Model Diagram

┌──────────────────────────────────────────────────────┐
│                 YOUR REPOSITORY                       │
│                                                      │
│  src/  package.json  .gitlab-ci.yml                  │
│                                                      │
└────────────────────┬─────────────────────────────────┘
                     │ git push / MR / tag / schedule

┌──────────────────────────────────────────────────────┐
│               GITLAB PLATFORM                         │
│                                                      │
│  Reads .gitlab-ci.yml → creates pipeline             │
│                                                      │
│  Pipeline:                                           │
│    Stage 1: lint     ──► all jobs run in parallel     │
│    Stage 2: test     ──► all jobs run in parallel     │
│    Stage 3: build    ──► all jobs run in parallel     │
│    Stage 4: deploy   ──► deploy to environment        │
│                                                      │
│  Each job → dispatched to a Runner                   │
│  Artifacts flow downstream between stages            │
│  Cache shared across pipelines for speed             │
│                                                      │
└──────────────────────────────────────────────────────┘

2. Learning Roadmap by Skill Level

Level 1 — Newbie

Goal: Create your first pipeline, understand the file format, and debug basic failures.

What .gitlab-ci.yml is

A YAML file at the root of your repository that defines your entire pipeline — stages, jobs, scripts, artifacts, caching, deployment rules, and more. GitLab reads it on every push and creates a pipeline accordingly.

Analogy: It’s like package.json for your CI/CD — a single declarative file that tells the system what to do.

YAML basics for GitLab CI

# Stages define the order of execution
stages:
  - lint
  - test
  - build

# A job definition
lint:
  stage: lint
  image: node:20-alpine
  script:
    - npm ci
    - npm run lint

# Another job
test:
  stage: test
  image: node:20-alpine
  script:
    - npm ci
    - npm test

Key YAML rules:

First pipeline

stages:
  - check
  - build

lint:
  stage: check
  image: node:20-alpine
  script:
    - npm ci
    - npm run lint

test:
  stage: check
  image: node:20-alpine
  script:
    - npm ci
    - npm test

build:
  stage: build
  image: node:20-alpine
  script:
    - npm ci
    - npm run build

lint and test are both in the check stage — they run in parallel. build is in the next stage — it runs after both lint and test succeed.

Understanding GitLab Runner

Runners execute your jobs. Every job runs inside a Docker container (by default) pulled from the image you specify.

# This job runs inside a node:20-alpine container
build:
  image: node:20-alpine
  script:
    - node --version
    - npm ci
    - npm run build

If you don’t specify image, the runner uses its default image (often ruby:latest — you almost always want to specify your own).

Debugging pipeline failures

  1. Click the failed job in the pipeline view. The log shows exactly which script line failed.
  2. Check the exit code. A non-zero exit code fails the job.
  3. Reproduce locally. Run the same commands on your machine in the same Node version.
  4. Add debug output:
    script:
      - node --version
      - npm --version
      - ls -la
      - npm ci
      - npm run lint
  5. Check variables. Use echo $CI_COMMIT_BRANCH to verify context.
  6. Enable debug logging. Set the variable CI_DEBUG_TRACE: "true" (verbose — use sparingly).

Common mistakes

MistakeWhat happensFix
No stages definitionJobs may run in unexpected orderAlways define stages explicitly
Missing imageUses runner default (often wrong)Always specify image: node:20-alpine
npm install instead of npm ciNon-deterministic buildsUse npm ci for lockfile-based installs
Wrong indentationYAML parse errorUse spaces (2-space), never tabs
Job name is a reserved keywordConfusing errorsAvoid stages, variables, include, default as job names
Not specifying stageJob goes to test stage by defaultAssign every job to a stage

5 beginner exercises

  1. Hello pipeline: Create a pipeline with one job that prints “Hello from GitLab CI.”
  2. Lint on push: Create a pipeline that runs npm run lint on every push.
  3. Two stages: Create a pipeline with check and build stages — lint + test in check, build in build.
  4. Break it: Introduce a lint error, push, and study the failure log.
  5. Node version: Print the Node.js version and npm version in a job.

Level 1 success criteria


Level 2 — Junior

Goal: Build practical team pipelines — multi-stage, cached, conditional, with artifacts and MR integration.

Multiple stages and job dependencies

stages:
  - validate
  - build
  - deploy

lint:
  stage: validate
  image: node:20-alpine
  script:
    - npm ci
    - npm run lint

typecheck:
  stage: validate
  image: node:20-alpine
  script:
    - npm ci
    - npm run typecheck

test:
  stage: validate
  image: node:20-alpine
  script:
    - npm ci
    - npm test

build:
  stage: build
  image: node:20-alpine
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

deploy:
  stage: deploy
  script:
    - echo "Deploying dist/"
  environment:
    name: production
    url: https://myapp.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Job dependencies with needs

By default, a job waits for the entire previous stage. needs lets you skip the stage barrier:

stages:
  - validate
  - build
  - deploy

lint:
  stage: validate
  script: npm run lint

test:
  stage: validate
  script: npm test

build:
  stage: build
  needs: [lint, test] # ← starts as soon as lint AND test finish
  script: npm run build
  artifacts:
    paths: [dist/]

deploy:
  stage: deploy
  needs: [build] # ← doesn't wait for entire build stage
  script: echo "Deploy"

needs creates a DAG (directed acyclic graph) — jobs can start earlier, making the pipeline faster.

Artifacts vs. cache

FeatureArtifactsCache
PurposePass files between jobs/stagesSpeed up repeated installs
ReliabilityGuaranteed (stored by GitLab)Best-effort (may be missing)
ScopeWithin one pipeline (or downloadable)Across pipelines
Exampledist/, test reportsnode_modules/, .npm/
Keywordartifacts:cache:

Cache configuration

# Global cache for all jobs
default:
  cache:
    key:
      files:
        - package-lock.json # Cache key based on lockfile hash
    paths:
      - node_modules/
    policy: pull-push # pull on start, push on success

# Job that only reads cache (faster)
deploy:
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull # Don't update the cache

pnpm cache:

default:
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store/
    policy: pull-push

.pnpm-setup:
  before_script:
    - corepack enable
    - corepack prepare pnpm@9 --activate
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile

Variables and secrets

# In .gitlab-ci.yml
variables:
  NODE_ENV: production
  CI: 'true'

# Use predefined variables
build:
  script:
    - echo "Branch: $CI_COMMIT_BRANCH"
    - echo "SHA: $CI_COMMIT_SHA"
    - echo "MR: $CI_MERGE_REQUEST_IID"
    - echo "Pipeline: $CI_PIPELINE_ID"

Secret variables: Set in GitLab UI under Settings → CI/CD → Variables.

Conditional rules

# Modern approach: use rules (not only/except)
deploy-staging:
  stage: deploy
  script: echo "Deploy to staging"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  script: echo "Deploy to production"
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
  environment:
    name: production

# MR-only job
preview:
  stage: deploy
  script: echo "Build preview"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

# Manual trigger
deploy-manual:
  stage: deploy
  script: echo "Manual deploy"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Merge request pipelines

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_TAG

# Now the pipeline runs on:
# - Every merge request
# - Pushes to main
# - Tags

Why use merge request pipelines? They prevent duplicate pipelines (one for the push, one for the MR) and give you access to MR-specific variables like $CI_MERGE_REQUEST_IID.

Reusable job templates

# Template (starts with a dot — not executed as a job)
.node-setup:
  image: node:20-alpine
  before_script:
    - npm ci
  cache:
    key:
      files: [package-lock.json]
    paths: [node_modules/]

# Extend the template
lint:
  extends: .node-setup
  stage: validate
  script:
    - npm run lint

test:
  extends: .node-setup
  stage: validate
  script:
    - npm test

build:
  extends: .node-setup
  stage: build
  script:
    - npm run build
  artifacts:
    paths: [dist/]

extends is like component composition in React — you define a base, then extend it with specifics.

Running Docker in GitLab CI

build-image:
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: '/certs'
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

GitLab provides built-in variables for its container registry ($CI_REGISTRY, $CI_REGISTRY_IMAGE, etc.).

5 mini project ideas

  1. React CI with MR pipeline: Lint, test, build on MRs; deploy on main.
  2. Next.js with artifacts: Build, upload artifact, deploy artifact to hosting.
  3. Docker build and push: Build a Docker image, push to GitLab Container Registry.
  4. Multi-stage with caching: Separate validate/build/deploy stages with npm cache.
  5. Scheduled dependency check: Weekly pipeline that runs npm audit.

Common mistakes and anti-patterns

Anti-patternWhy it’s badBetter approach
Using only/exceptDeprecated, confusing precedenceUse rules
No cachingEvery job reinstalls dependenciesCache node_modules or package store
Artifacts without expire_inStorage bloatSet expire_in: 1 week or appropriate duration
One massive jobSlow feedback, hard to debugSplit into stages and jobs
Duplicating job configDrift, maintenance painUse .templates and extends
No workflow:rulesDuplicate pipelines on MRsDefine workflow:rules to control pipeline creation
Secrets in .gitlab-ci.ymlCommitted to repo historyUse CI/CD variables in project settings

Level 2 success criteria


Level 3 — Senior

Goal: Build production-ready CI/CD — fast, secure, maintainable, with deployment safety and monorepo support.

Monorepo pipelines with changes

frontend:
  stage: build
  script:
    - cd frontend && npm ci && npm run build
  rules:
    - changes:
        - frontend/**
        - packages/shared/**

backend:
  stage: build
  script:
    - cd backend && npm ci && npm run build
  rules:
    - changes:
        - backend/**
        - packages/shared/**

Limitation: changes only works for push and MR pipelines, not schedules or triggers. For complex monorepos, use dynamic child pipelines.

Dynamic child pipelines

Generate pipeline YAML dynamically, then trigger it:

# Parent pipeline
generate-pipeline:
  stage: prepare
  image: node:20-alpine
  script:
    - node scripts/generate-pipeline.js > child-pipeline.yml
  artifacts:
    paths:
      - child-pipeline.yml

trigger-child:
  stage: execute
  trigger:
    include:
      - artifact: child-pipeline.yml
        job: generate-pipeline
    strategy: depend # Parent waits for child to finish
// scripts/generate-pipeline.js
// Detect changed packages and generate jobs only for them
const changedPackages = detectChanges();
const jobs = changedPackages.map((pkg) => ({
  [`build-${pkg}`]: {
    stage: 'build',
    script: [`cd packages/${pkg}`, 'npm ci', 'npm run build'],
    rules: [{ when: 'always' }],
  },
}));

const pipeline = {
  stages: ['build', 'test', 'deploy'],
  ...Object.assign({}, ...jobs),
};

console.log(YAML.stringify(pipeline));

This is extremely powerful for monorepos — you can generate exactly the jobs you need based on what changed, avoiding wasted compute.

Reusable pipeline templates with include

# In a shared templates repository: templates/frontend-ci.yml
.frontend-ci:
  image: node:20-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [node_modules/]
  before_script:
    - npm ci

lint:
  extends: .frontend-ci
  stage: validate
  script: npm run lint

test:
  extends: .frontend-ci
  stage: validate
  script: npm test

build:
  extends: .frontend-ci
  stage: build
  script: npm run build
  artifacts:
    paths: [dist/]
    expire_in: 1 week
# In consuming project: .gitlab-ci.yml
include:
  - project: 'my-org/ci-templates'
    ref: v2.0.0
    file: '/templates/frontend-ci.yml'

stages:
  - validate
  - build
  - deploy

deploy:
  stage: deploy
  script: echo "Deploy"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

include sources:

SourceSyntaxUse case
Local fileinclude: local: '/path.yml'Split large pipelines
Another projectinclude: project: 'org/repo'Shared templates across repos
Remote URLinclude: remote: 'https://...'External templates
Templateinclude: template: 'name.yml'GitLab-provided templates

Deployment strategy

deploy-staging:
  stage: deploy
  script:
    - ./deploy.sh staging
  environment:
    name: staging
    url: https://staging.myapp.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  script:
    - ./deploy.sh production
  environment:
    name: production
    url: https://myapp.com
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+/
      when: manual
      allow_failure: false # Pipeline waits for manual approval

Environment promotion

MR → preview (automatic)
  ↓ merge
main → staging (automatic)
  ↓ manual approval
staging → production (manual trigger, protected environment)
  ↓ monitor
production → rollback (re-deploy previous successful deployment)

Release pipelines

release:
  stage: release
  image: registry.gitlab.com/gitlab-org/release-cli:latest
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
  script:
    - echo "Creating release for $CI_COMMIT_TAG"
  release:
    tag_name: $CI_COMMIT_TAG
    name: 'Release $CI_COMMIT_TAG'
    description: 'Automated release'

CI optimization

TechniqueImpactHow
Cache dependencies30-60% faster installscache: with lockfile key
Use needs (DAG)Skip stage barriersneeds: [job-name]
Parallel keywordSplit test suitesparallel: 4
Interruptible jobsCancel on new pushinterruptible: true
changes rulesSkip unchanged packagesrules: changes:
Smaller Docker imagesFaster pull timesUse alpine variants
Artifacts only what’s neededLess upload/downloadPrecise paths:

Parallel and matrix-like patterns

# Parallel test splitting
test:
  stage: test
  image: node:20-alpine
  parallel: 4
  script:
    - npm ci
    - npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

# Matrix-like pattern with parallel:matrix
test:
  stage: test
  parallel:
    matrix:
      - NODE_VERSION: ["18", "20", "22"]
        OS_IMAGE: ["node:${NODE_VERSION}-alpine"]
  image: $OS_IMAGE
  script:
    - node --version
    - npm ci
    - npm test

Docker build and deployment

variables:
  DOCKER_TLS_CERTDIR: '/certs'

build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - |
      docker build \
        --cache-from $CI_REGISTRY_IMAGE:latest \
        --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        --tag $CI_REGISTRY_IMAGE:latest \
        .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest

Secure secrets management

PracticePriority
Use protected variables for production secretsCritical
Mask variables in logsHigh
Limit variable scope to environmentsHigh
Use OIDC for cloud access (no static keys)High
Rotate secrets regularlyMedium
Audit variable accessMedium

Self-hosted runners

Use when you need:

Runner executors:

ExecutorUse caseIsolation
DockerMost common — each job in a containerContainer-level
KubernetesAuto-scaling on K8sPod-level
ShellDirect on host (avoid for shared runners)None
Docker MachineAuto-scale VMs (legacy)VM-level

Rollback strategy

GitLab environments support one-click rollback:

deploy:
  environment:
    name: production
    url: https://myapp.com
    on_stop: stop-production

GitLab tracks every deployment per environment. You can re-deploy any previous successful deployment from the Environments UI.

Additional strategies:

Observability and notifications

# Slack notification on failure
notify-failure:
  stage: .post
  script:
    - |
      curl -X POST "$SLACK_WEBHOOK" \
        -H 'Content-type: application/json' \
        -d "{\"text\":\"❌ Pipeline failed: $CI_PROJECT_URL/-/pipelines/$CI_PIPELINE_ID\"}"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: on_failure

5 production-grade project examples

  1. Next.js monorepo CI/CD: Child pipelines for changed packages, artifact-based deploy, environment promotion from staging to production.
  2. Astro static site: Build, push to GitLab Pages or Cloudflare, preview on MR, production on tag.
  3. Docker microservice: Build image, scan for vulnerabilities, push to GitLab registry, deploy to Kubernetes.
  4. Automated releases: Conventional commit parsing, changelog generation, GitLab Release creation on tag.
  5. Multi-environment pipeline: dev/staging/production with progressive deployment and manual gates.

Level 3 success criteria


Level 4 — Expert

Goal: Design and operate CI/CD at organization scale — governance, multi-project, security, and cost optimization.

Organization-wide pipeline architecture

org/ci-templates/
├── templates/
│   ├── frontend-ci.yml
│   ├── backend-ci.yml
│   ├── docker-build.yml
│   ├── deploy-static.yml
│   ├── deploy-k8s.yml
│   ├── security-scan.yml
│   └── release.yml
└── README.md

All projects include from this repo:

include:
  - project: 'org/ci-templates'
    ref: v3.0.0
    file: '/templates/frontend-ci.yml'

Version pinning (ref: v3.0.0) prevents breaking changes from propagating instantly.

Pipeline governance

PolicyImplementation
All repos must run CIEnforce via group-level compliance pipelines
Templates cannot be modified locallyUse include with strict overrides
Production deploys require approvalProtected environments with required approvers
Secrets scoped to protected branchesProtected variables
Pipeline changes reviewedRequire MR approval for .gitlab-ci.yml
Security scans mandatoryInclude security templates at group level

Compliance pipelines (GitLab Premium+): Force specific jobs to run regardless of what the project defines.

Multi-project pipelines

# Trigger pipeline in another project
trigger-deploy:
  stage: deploy
  trigger:
    project: org/infrastructure
    branch: main
    strategy: depend
  variables:
    DEPLOY_VERSION: $CI_COMMIT_SHA
    DEPLOY_APP: frontend

Multi-environment deployment architecture

.deploy-template:
  image: alpine:latest
  script:
    - ./deploy.sh $DEPLOY_ENV
  rules:
    - when: manual
      allow_failure: false

deploy-staging:
  extends: .deploy-template
  stage: deploy
  variables:
    DEPLOY_ENV: staging
  environment:
    name: staging
    url: https://staging.myapp.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  extends: .deploy-template
  stage: deploy
  variables:
    DEPLOY_ENV: production
  environment:
    name: production
    url: https://myapp.com
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+/
      when: manual
      allow_failure: false

Security hardening

ThreatMitigation
Secret leakage in logsMask all sensitive variables
Compromised dependencySAST, dependency scanning, container scanning
Unauthorized production deployProtected environments with required approvers
Runner compromiseUse ephemeral runners, least-privilege
Pipeline injectionValidate all external inputs, avoid eval
Supply chain attackPin dependencies, sign images, verify provenance

GitLab built-in security scanning:

include:
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Dependency-Scanning.gitlab-ci.yml
  - template: Security/Container-Scanning.gitlab-ci.yml
  - template: Security/Secret-Detection.gitlab-ci.yml

OIDC and cloud authentication

deploy-aws:
  stage: deploy
  image: amazon/aws-cli:latest
  id_tokens:
    AWS_TOKEN:
      aud: https://gitlab.com
  script:
    - >
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn $AWS_ROLE_ARN
      --role-session-name "gitlab-ci-$CI_JOB_ID"
      --web-identity-token $AWS_TOKEN
      --duration-seconds 3600
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))
    - aws s3 sync dist/ s3://my-bucket/

Scaling runners

StrategyProsCons
GitLab SaaS shared runnersZero maintenanceCost at scale, shared resources
Self-hosted on VMsControl, private networkManual scaling and patching
Kubernetes executorAuto-scaling podsK8s complexity
Docker Machine (legacy)Auto-scale VMsBeing deprecated
Fleeting pluginModern auto-scaling for cloud VMsNewer, less battle-tested

Cost optimization

TechniqueSavings
Cache dependenciesReduce install time 30-60%
Use needs (DAG)Reduce total pipeline time
interruptible: trueCancel superseded pipelines
Skip jobs on unchanged pathsReduce unnecessary jobs
Use smaller runner instance typesLower per-minute cost
Right-size parallelismAvoid over-provisioning
Artifact retention policiesReduce storage cost

Disaster recovery for CI/CD

RiskMitigation
GitLab outageDocument manual deploy procedure
Lost pipeline config.gitlab-ci.yml is in version control
Runner fleet failureMulti-provider runner fleet, fallback to shared
Secret rotation failureDocumented rotation procedure, monitoring
Broken shared templateVersion pinning, canary rollout of template updates

Architecture review checklist

What expert engineers care about that juniors miss

Expert concernJunior blind spot
Pipeline cost per deployment”CI is free”
Template versioning and migrationCopy-paste YAML everywhere
Compliance enforcement”Trust the team”
Secret sprawl and rotation”Just add another variable”
Runner security boundaries”Shared runners are fine”
Artifact storage growthNo expiration set
Pipeline observabilityNo alerts on failure
Reproducible buildsVersion drift across runs
Recovery time for CI/CD failuresNo disaster recovery plan
Policy as codeManual enforcement

10 advanced engineering discussion topics

  1. Template economics: Should the platform team maintain CI templates as internal products with SLOs, changelogs, and migration guides?
  2. Runner fleet design: At what scale do self-hosted runners justify their maintenance cost? How do you measure?
  3. Child pipeline limits: What complexity threshold makes child pipelines essential for a monorepo?
  4. Multi-project cascading: How do you prevent cascading failures when one project’s pipeline triggers five others?
  5. Compliance pipelines: How do you balance governance with team autonomy?
  6. Migration strategy: How would you migrate 100 repos from Jenkins to GitLab CI without disrupting teams?
  7. Secret architecture: Design secrets management for an org with 200 projects, 10 environments, and 3 cloud providers.
  8. Cost allocation: How do you attribute CI costs to teams for budget planning?
  9. Incident response: A malicious commit modifies .gitlab-ci.yml to exfiltrate secrets. Design the detection and response playbook.
  10. Pipeline SLOs: What pipeline reliability and duration SLOs should you define, and how do you measure them?

3. Setup Guide

Step 1: Create .gitlab-ci.yml

Create the file at the root of your repository:

stages:
  - validate
  - build
  - deploy

default:
  image: node:20-alpine
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
.gitlab-ci.yml          ← main pipeline file
ci/
├── templates/
│   ├── node-setup.yml  ← reusable setup template
│   └── deploy.yml      ← reusable deploy template
└── scripts/
    ├── deploy.sh
    └── generate-pipeline.js
# .gitlab-ci.yml
include:
  - local: 'ci/templates/node-setup.yml'
  - local: 'ci/templates/deploy.yml'

Step 3: Example pipelines

Lint + test + build + deploy

stages:
  - validate
  - build
  - deploy

default:
  image: node:20-alpine

.node-cache:
  cache:
    key:
      files: [package-lock.json]
    paths: [node_modules/]

lint:
  extends: .node-cache
  stage: validate
  script:
    - npm ci
    - npm run lint

typecheck:
  extends: .node-cache
  stage: validate
  script:
    - npm ci
    - npm run typecheck

test:
  extends: .node-cache
  stage: validate
  script:
    - npm ci
    - npm test -- --ci
  artifacts:
    when: always
    reports:
      junit: junit.xml
    expire_in: 1 week

build:
  extends: .node-cache
  stage: build
  needs: [lint, typecheck, test]
  script:
    - npm ci
    - npm run build
  artifacts:
    paths: [dist/]
    expire_in: 1 week

deploy-staging:
  stage: deploy
  needs: [build]
  script:
    - echo "Deploy to staging"
  environment:
    name: staging
    url: https://staging.myapp.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  needs: [build]
  script:
    - echo "Deploy to production"
  environment:
    name: production
    url: https://myapp.com
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+/
      when: manual

Step 4: Framework-specific examples

React app (Vite)

stages:
  - validate
  - build
  - deploy

default:
  image: node:20-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [node_modules/]

lint:
  stage: validate
  script:
    - npm ci
    - npm run lint

test:
  stage: validate
  script:
    - npm ci
    - npm test -- --ci

build:
  stage: build
  needs: [lint, test]
  script:
    - npm ci
    - npm run build
  artifacts:
    paths: [dist/]
    expire_in: 1 week

pages:
  stage: deploy
  needs: [build]
  script:
    - mv dist/ public/
  artifacts:
    paths: [public/]
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Next.js app

stages:
  - validate
  - build
  - deploy

default:
  image: node:20-alpine
  cache:
    key:
      files: [package-lock.json]
    paths:
      - node_modules/
      - .next/cache/

lint:
  stage: validate
  script:
    - npm ci
    - npm run lint

typecheck:
  stage: validate
  script:
    - npm ci
    - npm run typecheck

test:
  stage: validate
  script:
    - npm ci
    - npm test

build:
  stage: build
  needs: [lint, typecheck, test]
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - .next/
      - public/
    expire_in: 1 week

deploy:
  stage: deploy
  needs: [build]
  image: node:20-alpine
  script:
    - npx vercel deploy --prod --token=$VERCEL_TOKEN
  environment:
    name: production
    url: https://myapp.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Astro static site

stages:
  - validate
  - build
  - deploy

default:
  image: node:20-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [node_modules/]

lint:
  stage: validate
  script:
    - npm ci
    - npm run lint

build:
  stage: build
  needs: [lint]
  script:
    - npm ci
    - npm run build
  artifacts:
    paths: [dist/]
    expire_in: 1 week

pages:
  stage: deploy
  needs: [build]
  script:
    - mv dist/ public/
  artifacts:
    paths: [public/]
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Step 5: pnpm / yarn examples

pnpm

default:
  image: node:20-alpine
  before_script:
    - corepack enable
    - corepack prepare pnpm@9 --activate
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile
  cache:
    key:
      files: [pnpm-lock.yaml]
    paths: [.pnpm-store/]

yarn

default:
  image: node:20-alpine
  before_script:
    - yarn install --frozen-lockfile
  cache:
    key:
      files: [yarn.lock]
    paths: [node_modules/]

Step 6: Cache strategy

What to cacheCache keyPath
npmpackage-lock.json hashnode_modules/
pnpmpnpm-lock.yaml hash.pnpm-store/
yarnyarn.lock hashnode_modules/
Next.js buildlockfile + source hash.next/cache/

Cache policies:

Step 7: Variables and secrets

  1. Go to Settings → CI/CD → Variables.
  2. Add variables:
    • VERCEL_TOKEN — masked, protected.
    • CLOUDFLARE_API_TOKEN — masked, protected.
    • DEPLOY_KEY — masked, protected.
  3. Use protected for production secrets (available only on protected branches).
  4. Use masked to hide values in job logs.

Step 8: Protected branches and environments

  1. Protected branches: Settings → Repository → Protected Branches. Protect main — require MR, require CI to pass.
  2. Protected environments: Settings → CI/CD → Environments. Mark production as protected, add required approvers.

Step 9: Docker integration

GitLab provides a built-in container registry at registry.gitlab.com/<namespace>/<project>.

build-and-push:
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: '/certs'
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Step 10: Example project structure

my-project/
├── .gitlab-ci.yml
├── ci/
│   ├── templates/
│   │   └── node-setup.yml
│   └── scripts/
│       └── deploy.sh
├── src/
├── tests/
├── public/
├── package.json
├── package-lock.json
├── Dockerfile
├── .dockerignore
└── README.md

Starter templates by project size

Small project (single app)

stages:
  - check
  - build

default:
  image: node:20-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [node_modules/]

lint:
  stage: check
  script:
    - npm ci
    - npm run lint

test:
  stage: check
  script:
    - npm ci
    - npm test

build:
  stage: build
  needs: [lint, test]
  script:
    - npm ci
    - npm run build
  artifacts:
    paths: [dist/]
    expire_in: 1 week

Medium project (app + deploy)

The full lint/typecheck/test/build/deploy example from Step 3.

Large project (monorepo + multi-env)

stages:
  - prepare
  - validate
  - build
  - deploy

include:
  - local: 'ci/templates/node-setup.yml'

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_TAG

generate-pipeline:
  stage: prepare
  image: node:20-alpine
  script:
    - node ci/scripts/generate-pipeline.js > child-pipeline.yml
  artifacts:
    paths: [child-pipeline.yml]
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        - packages/**

trigger-child:
  stage: validate
  trigger:
    include:
      - artifact: child-pipeline.yml
        job: generate-pipeline
    strategy: depend

deploy-staging:
  stage: deploy
  script: ./ci/scripts/deploy.sh staging
  environment:
    name: staging
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  script: ./ci/scripts/deploy.sh production
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+/
      when: manual

4. Cheatsheet

Common .gitlab-ci.yml syntax

stages: # Define execution order
  - validate
  - build
  - deploy

default: # Defaults for all jobs
  image: node:20-alpine
  before_script:
    - npm ci

variables: # Global variables
  NODE_ENV: production

workflow: # Control when pipelines run
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

include: # Import external configs
  - local: '/ci/templates.yml'
  - project: 'org/templates'
    ref: v1.0
    file: '/frontend.yml'

Keyword reference

KeywordPurposeExample
stagesDefine stage orderstages: [lint, test, build]
imageDocker image for the jobimage: node:20-alpine
scriptCommands to runscript: [npm ci, npm test]
before_scriptCommands before scriptbefore_script: [npm ci]
after_scriptCommands after script (always runs)after_script: [echo "done"]
stageAssign job to stagestage: build
rulesConditional executionSee rules section
needsDAG dependenciesneeds: [lint, test]
dependenciesWhich artifacts to downloaddependencies: [build]
artifactsFiles to preserveartifacts: paths: [dist/]
cacheFiles to cache across runsSee cache section
variablesJob-level variablesvariables: NODE_ENV: test
environmentDeployment targetenvironment: name: staging
extendsInherit from templateextends: .node-setup
includeImport external YAMLSee include section
triggerTrigger child/downstream pipelinetrigger: project: org/repo
parallelSplit job into parallel instancesparallel: 4
interruptibleAllow cancellation on new pushinterruptible: true
retryRetry on failureretry: max: 2
timeoutJob timeouttimeout: 10 minutes
allow_failureDon’t fail pipeline if this failsallow_failure: true
whenWhen to runwhen: manual, on_success, on_failure, always
resource_groupPrevent concurrent deploysresource_group: production
servicesSidecar containersservices: [docker:24-dind]
tagsSelect specific runnerstags: [docker, linux]

Rules syntax

rules:
  # Branch condition
  - if: $CI_COMMIT_BRANCH == "main"

  # Tag condition
  - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/

  # MR pipeline
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"

  # Manual trigger
  - if: $CI_COMMIT_BRANCH == "main"
    when: manual

  # Path changes
  - changes:
      - src/**
      - package.json

  # Never run
  - when: never

  # Combine conditions
  - if: $CI_COMMIT_BRANCH == "main"
    changes:
      - src/**

Artifacts patterns

artifacts:
  paths:
    - dist/
    - coverage/
  expire_in: 1 week
  when: always              # Upload even on failure

# JUnit test reports (shown in MR)
artifacts:
  reports:
    junit: junit.xml
    coverage_report:
      coverage_format: cobertura
      path: coverage/cobertura.xml

Cache patterns

# npm
cache:
  key:
    files: [package-lock.json]
  paths: [node_modules/]

# pnpm
cache:
  key:
    files: [pnpm-lock.yaml]
  paths: [.pnpm-store/]

# Multiple caches
cache:
  - key:
      files: [package-lock.json]
    paths: [node_modules/]
  - key: next-cache-$CI_COMMIT_REF_SLUG
    paths: [.next/cache/]

Docker in GitLab CI

# Build and push to GitLab Container Registry
build-image:
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: '/certs'
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

# Using Kaniko (no Docker daemon needed)
build-image-kaniko:
  image:
    name: gcr.io/kaniko-project/executor:v1.21.0-debug
    entrypoint: ['']
  script:
    - |
      /kaniko/executor \
        --context $CI_PROJECT_DIR \
        --dockerfile $CI_PROJECT_DIR/Dockerfile \
        --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

Common deployment patterns

# Deploy to GitLab Pages
pages:
  stage: deploy
  script:
    - mv dist/ public/
  artifacts:
    paths: [public/]
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# Deploy to Cloudflare Pages
deploy-cloudflare:
  stage: deploy
  image: node:20-alpine
  script:
    - npx wrangler pages deploy dist/ --project-name=my-site
  environment:
    name: production
    url: https://myapp.com
  variables:
    CLOUDFLARE_API_TOKEN: $CLOUDFLARE_API_TOKEN

# Deploy via SSH
deploy-ssh:
  stage: deploy
  script:
    - apt-get update && apt-get install -y openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - scp -r dist/ deploy@server:/var/www/app/

Debugging commands

# In a job script
- echo "Branch: $CI_COMMIT_BRANCH"
- echo "SHA: $CI_COMMIT_SHA"
- echo "Source: $CI_PIPELINE_SOURCE"
- echo "MR: $CI_MERGE_REQUEST_IID"
- node --version
- npm --version
- ls -la
- env | sort | grep CI_

Common pipeline failures

ErrorCauseFix
YAML syntax errorIndentation or structure issueUse GitLab CI Lint (CI/CD → Editor → Lint)
job: script not foundMissing script keywordEvery job needs script:
Job stuck / pendingNo runner available with matching tagsCheck runner tags and availability
npm ERR! could not determine executableWrong image or missing depsVerify image: and before_script
Artifact not found in downstream jobWrong dependencies or needsVerify artifact paths and job references
Cache not restoringKey mismatch or cache expiredVerify cache:key matches across jobs
Protected variable missingJob running on unprotected branchUse protected branches or unprotect the variable
Docker build failsDinD not configuredAdd services: [docker:24-dind] and TLS cert dir

Performance optimization

TechniqueImpact
Cache with lockfile key30-60% faster installs
needs (DAG mode)Jobs start sooner
interruptible: trueCancel old pipelines
parallel:Split tests across runners
changes: rulesSkip unchanged packages
Smaller imagesFaster container pull
expire_in on artifactsReduce storage
policy: pull on deploy jobsDon’t re-upload cache

Security best practices

PracticePriority
Use rules not only/exceptHigh
Mask and protect sensitive variablesCritical
Pin Docker image versionsHigh
Use protected environments for productionCritical
Enable SAST and dependency scanningHigh
Use OIDC for cloud accessHigh
Audit variable access regularlyMedium
Use resource_group for deploy jobsMedium
Review .gitlab-ci.yml changes in MRsHigh

5. Real-World Engineering Mindset

CI for React / Next.js / Astro

Problem: Every MR needs fast, reliable feedback on code quality.

Strategies:

StrategyProsCons
Single job: lint → test → buildSimpleSlower, sequential feedback
Parallel jobs with needsFast feedback per checkMore YAML, repeated installs
Template-based with extendsDRY, maintainableSlight learning curve

By team size:

Senior choice: Parallel lint/typecheck/test in validate stage, build in build stage with needs, cache on lockfile hash. Add interruptible: true to save minutes.


Deploying static sites

Problem: Ship HTML/CSS/JS reliably to a CDN.

Strategies:

StrategyProsCons
GitLab PagesFree, integrated, zero configGitLab-hosted only
Cloudflare Pages (via CLI)Edge CDN, fastRequires API token
AWS S3 + CloudFrontFull controlMore setup
Vercel (via CLI)Great DXExternal platform

Senior choice: GitLab Pages for simple static sites (Astro, Vite React). Cloudflare Pages for edge performance. Vercel for Next.js with SSR.


Deploying Docker containers

Problem: Build images reproducibly, scan, and push to registry.

Strategies:

StrategyProsCons
Docker-in-Docker (DinD)Standard, well-documentedPrivileged mode needed
KanikoNo privileged mode, more secureSlightly different build behavior
BuildahRootless, OCI-compliantLess common in GitLab CI
# Kaniko — no DinD required
build:
  image:
    name: gcr.io/kaniko-project/executor:v1.21.0-debug
    entrypoint: ['']
  script:
    - |
      /kaniko/executor \
        --context $CI_PROJECT_DIR \
        --dockerfile Dockerfile \
        --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        --cache=true \
        --cache-repo=$CI_REGISTRY_IMAGE/cache

Senior choice: Kaniko for security-conscious teams (no privileged containers). DinD for simplicity when privileged is acceptable.


Monorepo selective pipelines

Problem: Don’t rebuild everything when only one package changed.

Strategies:

StrategyProsCons
rules: changes:SimpleDoesn’t handle transitive deps
Dynamic child pipelinesMost flexibleMore complex setup
Turborepo/Nx integrationBuild-tool awareTool-specific

Senior choice: rules: changes: for simple monorepos. Dynamic child pipelines with change detection scripts for complex ones.


Merge request preview deployment

Problem: Reviewers want to see changes live before approving.

deploy-preview:
  stage: deploy
  script:
    - npx wrangler pages deploy dist/ --project-name=myapp --branch=$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
  environment:
    name: preview/$CI_MERGE_REQUEST_IID
    url: https://$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME.myapp.pages.dev
    on_stop: stop-preview
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

stop-preview:
  stage: deploy
  script:
    - echo "Cleanup preview"
  environment:
    name: preview/$CI_MERGE_REQUEST_IID
    action: stop
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual

Senior choice: Use platform-native previews (Vercel, Cloudflare) for the best DX. Use GitLab environments with on_stop for cleanup.


Environment promotion

Problem: Code should flow through dev → staging → production safely.

Senior choice: Auto-deploy to staging on main. Manual gate for production with protected environment. Track all deployments in GitLab Environments for rollback.


Database migration during deployment

Problem: Migration failure can break the app.

Strategies:

StrategyRisk levelTrade-off
Migrate before deployMediumMigration applied even if deploy fails
Deploy then migrateMediumApp may error if schema mismatch
Expand/contract (backward-compatible)LowRequires discipline

Senior choice: Expand/contract. Make migrations backward-compatible so old and new code coexist. Always test migrations in staging first.


Release automation

release:
  stage: release
  image: registry.gitlab.com/gitlab-org/release-cli:latest
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+/
  script:
    - echo "Creating release"
  release:
    tag_name: $CI_COMMIT_TAG
    name: 'Release $CI_COMMIT_TAG'
    description: 'Automated release for $CI_COMMIT_TAG'

Senior choice: Tag-based releases with automated changelog from conventional commits.


Canary deployment

Problem: Reduce risk by releasing to a subset of users first.

Senior choice: Use feature flags or traffic splitting. GitLab’s incremental rollout is available for Kubernetes deployments.


Blue/Green deployment

Problem: Need instant rollback capability.

Senior choice: Maintain two environments. Deploy to the inactive one, verify, then switch traffic. Requires infrastructure support.


Rollback after failed release

Problem: Production is broken after deploy.

Strategies:

StrategyRecovery time
GitLab environment rollbackMinutes
Re-deploy previous image tagMinutes
Git revert + pipeline5-10 minutes
Feature flag disableSeconds

Senior choice: Use GitLab’s built-in environment rollback for immediate recovery. Test rollback procedures regularly.


Scheduled jobs

nightly-tests:
  stage: test
  script:
    - npm ci
    - npm run test:e2e
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"

Set up in CI/CD → Schedules. Common patterns: nightly E2E tests, weekly dependency audits, monthly security scans.


Nightly testing

Senior choice: Run the broadest test suite nightly — E2E, full matrix, integration tests. Run focused tests on MRs. Alert the team on failure.


Security scanning

include:
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Dependency-Scanning.gitlab-ci.yml
  - template: Security/Secret-Detection.gitlab-ci.yml

Senior choice: Include GitLab security templates. Run SAST and dependency scanning on MRs. Review vulnerability reports in merge request widgets.


Dependency updates

Senior choice: Use GitLab’s Dependency Bot or Renovate Bot. Auto-merge patches that pass CI. Manually review major updates. Group minor updates.


6. Brainstorm / Open Questions

Architecture

  1. Should this be one pipeline or multiple pipelines?
  2. When should you use child pipelines vs. multi-project pipelines?
  3. At what complexity should you extract templates to a shared project?
  4. Should every service have its own .gitlab-ci.yml or share one?
  5. How do you balance standardization with team autonomy?
  6. When should a child pipeline be introduced for a monorepo?
  7. What is the right granularity for stages?

Scaling

  1. What happens when your pipeline takes 30 minutes instead of 3?
  2. How many concurrent jobs can your runner fleet handle at peak?
  3. When should you move from shared runners to self-hosted?
  4. How do you handle 20 teams pushing simultaneously?
  5. How do you prevent one team’s heavy pipeline from starving others?
  6. What is the right level of parallelism for test suites?
  7. How do you measure and reduce pipeline queue time?

Security

  1. How can secrets leak in CI logs even when masked?
  2. Which jobs should never have access to production secrets?
  3. Should all pipeline config changes require MR approval?
  4. How do you detect if a .gitlab-ci.yml change is malicious?
  5. What happens if a runner is compromised?
  6. How do you handle secrets rotation across 100 projects?
  7. When should OIDC replace static cloud credentials?

DX / Maintainability

  1. Can a new engineer understand the pipeline in 10 minutes?
  2. How do you prevent “YAML sprawl” across teams?
  3. What should be in shared templates vs. project-specific config?
  4. How do you test pipeline changes safely?
  5. How do you version and migrate shared templates?
  6. How should a monorepo detect which apps changed?
  7. What is the right level of abstraction for CI templates?

Cost

  1. Which jobs cost the most without proportional value?
  2. Should full test suites run on every MR or only on main?
  3. Are caches actually saving you time? How do you measure?
  4. When do self-hosted runners become cheaper than shared?
  5. How much does artifact storage cost at your scale?
  6. What is the developer productivity cost of slow pipelines?

Reliability

  1. What happens if deployment succeeds but migration fails?
  2. How do you design for pipeline re-runs after flaky failures?
  3. How do you ensure artifacts are traceable and reproducible?
  4. What should be retried automatically vs. investigated manually?
  5. How do you detect partial failures that look successful?
  6. What is your rollback RTO (Recovery Time Objective)?

Release Strategy

  1. When should you release on merge vs. on tag?
  2. Should production deploy require manual approval?
  3. How do you coordinate releases across dependent services?
  4. What does a safe canary look like for your stack?
  5. How do you reconcile fast shipping with deployment safety?

7. Practice Questions

Beginner (Level 1)

Q1. Single choice: Where does the GitLab CI/CD configuration file live?

AnswerB. GitLab reads .gitlab-ci.yml from the repository root.

Q2. True/False: Jobs in the same stage run sequentially.

AnswerFalse. Jobs in the same stage run in parallel. Stages execute sequentially — the next stage starts only after all jobs in the current stage succeed.

Q3. Fill in the blank: Every job must have a _______ keyword.

Answerscript. Without script, GitLab doesn't know what commands to run.

Q4. Single choice: What command should you use instead of npm install in CI?

AnswerB. npm ci performs a clean, deterministic install from the lockfile.

Q5. Debugging: Your pipeline shows “Job is stuck.” What should you check?

Answer 1. Check if any runners are available for the project.
2. Check if the job requires specific runner tags: that no runner matches.
3. Check if runners are online in Settings → CI/CD → Runners.

Q6. Multiple choice: What does the image keyword specify?

AnswerB. image specifies the Docker image used as the execution environment for the job.

Q7. Matching: Match each concept to its meaning.

ConceptMeaning
A. Pipeline1. A file produced by a job for downstream use
B. Stage2. The agent that executes jobs
C. Runner3. A logical grouping of parallel jobs
D. Artifact4. The full execution of .gitlab-ci.yml for a commit
AnswerA→4, B→3, C→2, D→1

Q8. True/False: If you don’t specify stage: for a job, it defaults to the test stage.

AnswerTrue. Jobs without an explicit stage: are assigned to the test stage by default.

Q9. Scenario: You push to a branch. The pipeline runs but no jobs execute. The pipeline is empty. What is likely wrong?

Answer Your workflow:rules or job rules are filtering out the current pipeline source/branch. Check that your rules match the branch or event type being triggered.

Q10. Fill in the blank: To specify which Docker image a job runs in, use the _______ keyword.

Answerimage

Junior (Level 2)

Q11. Single choice: What is the difference between cache and artifacts?

AnswerB. Cache is best-effort and speeds up repeated installs across pipelines. Artifacts are guaranteed and pass files between jobs within (or across) pipelines.

Q12. True/False: needs: [build] means the job waits for the entire previous stage to complete.

AnswerFalse. needs creates a DAG dependency — the job starts as soon as the specified jobs finish, regardless of stage completion.

Q13. Debugging: A job can’t find $DEPLOY_TOKEN, but you set it as a protected variable. The pipeline runs on a feature branch. Why?

AnswerProtected variables are only available on protected branches and protected tags. A feature branch is not protected, so the variable is unavailable. Either unprotect the variable or run the job only on protected branches.

Q14. Fill in the blank: To prevent duplicate pipelines for push and MR events, define workflow: _______.

Answerrules. Define workflow:rules to control when pipelines are created (e.g., only for MR events and pushes to main).

Q15. Scenario: You want to pass the dist/ folder from a build job to a deploy job. Which keyword do you use?

Answerartifacts. Define artifacts: paths: [dist/] in the build job. The deploy job (in a later stage or with needs: [build]) will automatically download it.

Q16. Multiple choice: Which rules condition runs a job only on merge requests?

AnswerB. $CI_PIPELINE_SOURCE == "merge_request_event" is the correct rules-based approach. D (only:) works but is deprecated in favor of rules:.

Q17. Matching: Match the cache policy to its behavior.

PolicyBehavior
A. pull-push1. Only upload cache at end
B. pull2. Download at start, upload at end
C. push3. Only download cache at start
AnswerA→2, B→3, C→1

Q18. True/False: extends: .template works like class inheritance — child overrides parent values.

AnswerTrue. Properties defined in the job override the template's values. Arrays and mappings are merged or overridden depending on the key.

Q19. Scenario: Your cache key is default. Every branch shares the same cache. Sometimes the cache is corrupted, causing failures. What should you fix?

AnswerUse a file-based cache key: key: files: [package-lock.json]. This ties the cache to the lockfile hash, so different dependency versions get different caches.

Q20. Single choice: To run a job only when files in src/ changed, use:

AnswerA. rules: - changes: [src/**] is the correct syntax. C uses the deprecated only: syntax.

Senior / Expert (Level 3–4)

Q21. Scenario: You have a monorepo with 20 packages. rules: changes: misses changes in shared dependencies. What approach do you use?

AnswerDynamic child pipelines. Write a script that analyzes the dependency graph, detects affected packages from changed files, generates a .yml with only the needed jobs, and triggers it as a child pipeline.

Q22. True/False: Protected variables are available in pipelines triggered by unprotected branches.

AnswerFalse. Protected variables are only available in pipelines running on protected branches or protected tags.

Q23. Design question: Deployment succeeded, but the database migration failed midway. Users see errors. What went wrong and how do you prevent it?

Answer 1. The migration wasn't wrapped in a transaction.
2. The migration wasn't backward-compatible (expand/contract pattern not used).
3. There was no health check after migration to gate the traffic switch.
Prevention: Use backward-compatible migrations, test in staging, gate deployment on health checks, and have a rollback plan.

Q24. Single choice: Which is more secure for building Docker images in GitLab CI?

AnswerB. Kaniko runs without privileged mode and without a Docker daemon, reducing the attack surface. DinD requires privileged mode which is a security risk. Shell executor has no isolation.

Q25. Scenario: A team pushes to main 50 times a day. Each pipeline takes 15 minutes and costs $0.10/minute on shared runners. Estimate the monthly CI cost and propose optimizations.

Answer Cost: 50 pushes × 15 min × $0.10 × 30 days = $2,250/month.
Optimizations:
1. interruptible: true — cancel superseded pipelines (could save 30-50%).
2. Caching — reduce install time by 30-60%.
3. needs DAG — reduce total pipeline time.
4. changes: rules — skip unchanged packages.
5. Self-hosted runners at this scale would likely be cheaper.

Q26. Fill in the blank: To prevent two deploy jobs from running concurrently, use resource_group: _______.

AnswerA meaningful group name like production. resource_group: production ensures only one deploy to production runs at a time.

Q27. Design question: You need to migrate 50 projects from Jenkins to GitLab CI. How do you plan it?

Answer 1. Create shared CI templates for common patterns (frontend, backend, Docker).
2. Start with low-risk, small projects to validate templates.
3. Run Jenkins and GitLab CI in parallel for a transition period.
4. Provide team-by-team migration support and documentation.
5. Track migration progress and collect feedback.
6. Decommission Jenkins only after all projects pass validation.

Q28. Multiple choice: Which features make GitLab CI/CD better for monorepos than GitHub Actions? (Select all)

AnswerA, B, and C. Dynamic child pipelines and job-level changes: rules are strong monorepo features. Multi-project pipelines coordinate across repos. D is a GitHub Actions advantage, not GitLab.

Q29. Debugging: A deploy to production ran without the required manual approval. How could this happen?

Answer 1. The environment is not configured as protected.
2. The user has the right role to bypass protection.
3. The rules: don't include when: manual.
4. The pipeline was triggered via API with sufficient permissions.
Check environment protection settings and job rules.

Q30. Design question: Design an incident response plan for when a CI template update breaks pipelines across 100 projects.

Answer 1. Detection: Monitor pipeline failure rate. Alert on sudden spike.
2. Containment: Projects pin to template version — unaffected until they update. For those affected, revert the template change immediately.
3. Recovery: Push a fix to the template repo. Projects re-run pipelines.
4. Prevention: Version pin all template references. Test template changes in a canary project first. Require MR review for template changes. Maintain a changelog.

8. Personalized Recommendations

GitLab CI/CD patterns most useful for your stack

  1. MR pipelines: Lint + typecheck + test + build on every merge request.
  2. Lockfile-based caching: Speed up installs with cache: key: files:.
  3. Artifacts for build output: Pass dist/ between stages.
  4. GitLab Pages deployment: Free static hosting for Astro/React.
  5. Environment promotion: Auto-staging on main, manual production on tag.
  6. extends templates: DRY job definitions across similar jobs.
  7. needs DAG: Faster pipelines by skipping stage barriers.
  8. Interruptible jobs: Cancel superseded pipelines on rapid pushes.

What to learn first

PriorityTopic
1Basic .gitlab-ci.yml structure, stages, jobs
2Cache and artifacts
3rules for conditional execution
4extends for DRY templates
5needs for DAG mode
6MR pipelines (workflow:rules)
7Environments and deployment
8Variables and secrets
9Docker integration
10include for shared templates

Which pipelines to build first

OrderPipelineWhy
1stBasic CI: lint + test + buildFoundation
2ndMR pipeline with workflow:rulesPrevent duplicates, MR integration
3rdDeploy to staging on mainClose the loop
4thDeploy to production on tagRelease process
5thPreview deployment on MRBetter code review
6thNightly/scheduled security scanSecurity posture
7thRelease automationProfessional releases

Common mistakes frontend engineers make

MistakeWhy it happensFix
Not defining stagesSeems optionalAlways define stages explicitly
Using only/exceptFound in old tutorialsUse rules instead
No workflow:rulesDon’t know about duplicate pipelinesDefine workflow rules
Not caching”It works without it”Cache on lockfile hash
No expire_in on artifactsSeems harmlessSet expiration to avoid storage bloat
Using npm installHabitUse npm ci
Secrets in YAMLQuick and dirtyUse CI/CD variables in settings
Ignoring MR pipeline featuresNot awareUse test reports, code quality integration

How to evolve from simple to production-grade

Phase 1: Basic CI
  └── lint + test + build in stages

Phase 2: Optimized CI
  └── cache, needs (DAG), interruptible, MR pipelines

Phase 3: Deployment
  └── staging auto-deploy, production manual gate

Phase 4: Templates
  └── extends, include from shared project

Phase 5: Security
  └── protected environments, masked variables, SAST scanning

Phase 6: Automation
  └── release automation, scheduled scans, dependency updates

Phase 7: Monorepo
  └── changes rules, child pipelines

Phase 8: Platform
  └── org-wide templates, compliance pipelines, runner fleet

30-day learning plan

Week 1: Foundations (Days 1–7)

DayTaskDeliverable
1Read Big Picture section, understand conceptsMental model
2Create first .gitlab-ci.yml with one jobWorking pipeline
3Add lint, test, and build in separate stagesMulti-stage pipeline
4Add caching on lockfile hashFaster installs
5Add needs to skip stage barriersDAG pipeline
6Break a job intentionally, study failure logsDebugging confidence
7Add extends for shared setup templateDRY pipeline

Week 2: Team workflows (Days 8–14)

DayTaskDeliverable
8Add workflow:rules for MR pipelinesNo duplicate pipelines
9Add rules for conditional executionBranch/event-based jobs
10Set up CI/CD variables (secrets)Secure config
11Add artifacts for build outputBuild passed between stages
12Add test reports (JUnit format)Reports in MR widget
13Add interruptible: trueCancel old pipelines
14Review and document your pipelineMaintainable system

Week 3: Deployment (Days 15–21)

DayTaskDeliverable
15Deploy to GitLab Pages (Astro/React)Working deployment
16Add staging environment on mainEnvironment tracking
17Add production environment with manual gateSafe production deploy
18Add Slack/notification on failureTeam awareness
19Add Docker image build and pushContainer pipeline
20Add scheduled pipeline for security scanNightly scans
21Add release automation on tagVersioned releases

Week 4: Advanced (Days 22–30)

DayTaskDeliverable
22Study include from shared templatesReusable patterns
23Create a shared template projectOrg-wide reuse
24Add rules: changes: for monorepoSelective CI
25Study child pipelinesDynamic pipeline generation
26Study self-hosted runnersArchitecture knowledge
27Add OIDC for cloud authenticationBetter security
28Audit pipeline for security checklistHardened pipeline
29Calculate and optimize pipeline costCost awareness
30Write architecture decision recordDocumentation

Summary, Next Steps, and Advanced Topics

Concise Summary

GitLab CI/CD is a batteries-included automation platform deeply integrated with the GitLab ecosystem. For a frontend engineer, the key path to mastery is:

  1. Start with a simple pipeline: lint, test, build in stages.
  2. Optimize with caching, DAG (needs), and interruptible.
  3. Deploy with environments, protection rules, and artifacts.
  4. Scale with extends, include, and shared templates.
  5. Harden with protected variables, OIDC, and security scanning.
  6. Govern with compliance pipelines and org-wide templates.

The key mindset: your pipeline is production infrastructure. Treat .gitlab-ci.yml with the same rigor as application code.

Next Steps

  1. Create a .gitlab-ci.yml for your current project today.
  2. Add caching and workflow:rules.
  3. Add a deployment to staging with an environment.
  4. Set up protected environment for production.
  5. Study include and build shared templates.
  6. Add security scanning and scheduled pipelines.

Suggested Advanced Topics

TopicWhy it matters
Dynamic child pipelines for monoreposScale CI for large codebases
GitLab compliance pipelinesEnforce standards across projects
Multi-project pipeline orchestrationCoordinate dependent services
OIDC-based cloud authenticationEliminate static credentials
Kubernetes executor for runnersAuto-scaling runner fleet
GitLab Container Registry integrationBuilt-in image management
Review Apps with dynamic environmentsPer-MR preview environments
Merge trainsSafe continuous delivery to main
Feature flags (GitLab)Decouple deployment from release
Pipeline analytics and insightsData-driven pipeline optimization
Kaniko and rootless image buildsSecure container builds
GitLab Pages advanced usageCustom domains, redirects, SPA routing
Vault integration for secretsEnterprise secret management
Auto DevOpsZero-config CI/CD for standard apps
Pipeline efficiency dashboardMonitor cost and duration trends

Edit page
Share this post:

Previous Post
GitHub Actions — Complete Deep-Dive Engineering Guide
Next Post
🚀 GitLab CI/CD — Roadmap & Deep Dive Guide