Overview and What You Will Learn
GitLab CI defines pipelines in a .gitlab-ci.yml file at the repository root. No external tools, no separate dashboard -- the pipeline lives where the code lives and is tightly integrated with merge requests. GitLab CI is common at organisations running self-hosted GitLab for compliance, and at teams that prefer having their entire DevOps toolchain in one platform.
By the end of this lab you will:
- Write a complete
.gitlab-ci.ymlwith stages, rules, and environments - Configure GitLab runners with correct executor types and tags
- Use artifacts and cache correctly to pass data between jobs
- Deploy to Kubernetes with GitLab CI environment tracking
- Use rules to control exactly when each job runs
- Understand GitLab CI vs GitHub Actions and when to use each
Why This Matters in Production
GitLab CI is the default choice for self-hosted DevOps platforms. Organisations that cannot use GitHub.com -- due to data residency, compliance, or cost -- often run GitLab on-premise. Engineers who know GitLab CI are immediately productive in these environments without needing to set up external CI tools.
Core Principles
GitLab CI pipeline execution model:
+------------------------------------------+| .gitlab-ci.yml defines: || stages: [build, test, scan, deploy] |+------------------------------------------+ | push or MR event | v+------------------------------------------+| GitLab creates pipeline || Resolves job-to-stage assignments || Builds dependency DAG |+------------------------------------------+ | v+------------------------------------------+| Jobs in same stage run in parallel || Next stage starts when all jobs pass || Failed job blocks subsequent stages |+------------------------------------------+ | v+------------------------------------------+| Runner picks up job from queue || Executes in Docker container || Reports result back to GitLab |+------------------------------------------+Key GitLab CI concepts vs GitHub Actions:
GitLab CI GitHub Actions----------- --------------stages: jobs with needs:.gitlab-ci.yml .github/workflows/*.yamlrunners runnersartifacts artifactscache cacheenvironments environmentsrules: if: conditions$CI_COMMIT_SHA ${{ github.sha }}$CI_REGISTRY_IMAGE ${{ env.ECR_REGISTRY }}/imageDetailed Step-by-Step Practical Lab
Milestone 1 -- Basic .gitlab-ci.yml structure
## .gitlab-ci.yml ## Define stage orderstages: - build - test - scan - deploy ## Variables available to all jobsvariables: ## GitLab built-in: full image path in project registry IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA DOCKER_DRIVER: overlay2 DOCKER_TLS_CERTDIR: '' ## YAML anchor -- reusable configuration block.node-base: &node-base image: node:20-alpine cache: key: $CI_COMMIT_REF_SLUG paths: - node_modules/ before_script: - npm ci 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 -t $IMAGE_NAME . - docker push $IMAGE_NAMEMilestone 2 -- Tests, artifacts, and cache
unit-tests: stage: test <<: *node-base ## merge the node-base anchor script: - npm test -- --coverage ## Artifacts: files passed to downstream jobs or kept for inspection artifacts: ## JUnit format: GitLab parses and shows test results on MR reports: junit: junit.xml ## Coverage report: available to download paths: - coverage/ ## How long to keep artifacts expire_in: 1 week ## Upload even when job fails -- critical for test result inspection when: always lint: stage: test <<: *node-base script: - npm run lint - npm run format:check ## Coverage gate: fail if coverage drops below thresholdcoverage-gate: stage: test <<: *node-base needs: [unit-tests] ## wait for unit-tests specifically script: - COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct') - echo "Coverage is $COVERAGE%" - | python3 -c "import syscoverage = float('$COVERAGE')if coverage < 80: print(f'Coverage {coverage}% below 80% threshold') sys.exit(1)print('Coverage gate passed')"Milestone 3 -- Security scanning
trivy-scan: stage: scan image: name: aquasec/trivy:latest entrypoint: [''] variables: GIT_STRATEGY: none ## skip git checkout -- not needed for image scan script: ## Scan for HIGH and CRITICAL vulnerabilities - trivy image --exit-code 1 --severity HIGH,CRITICAL --no-progress --format json --output trivy-report.json $IMAGE_NAME artifacts: paths: - trivy-report.json expire_in: 1 week when: always ## keep report even on failure ## Only run on main branch and MRs rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_PIPELINE_SOURCE == 'merge_request_event'Milestone 4 -- Rules for conditional job execution
## Rules control WHEN a job runs -- replacing the older 'only/except' deploy-staging: stage: deploy script: - kubectl set image deployment/payment-api payment-api=$IMAGE_NAME -n staging environment: name: staging url: https://staging.payment.internal rules: ## Run automatically on default branch - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: on_success ## Never run on MRs - when: never deploy-production: stage: deploy script: - kubectl set image deployment/payment-api payment-api=$IMAGE_NAME -n production environment: name: production url: https://api.payment.razorpay.com rules: ## Manual trigger only on default branch - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual - when: never needs: - deploy-staging ## must succeed before production can be triggered ## Skip expensive jobs for documentation-only changesunit-tests: rules: ## Skip if only .md files changed - changes: - '**/*.md' - 'docs/**' when: never - when: on_successMilestone 5 -- Environments and deployment tracking
## GitLab tracks every deployment to named environments## View in: Deployments > Environments deploy-staging: stage: deploy image: bitnami/kubectl:latest script: ## Use GitLab CI/CD variable for kubeconfig - echo "$KUBECONFIG_STAGING" > /tmp/kubeconfig - export KUBECONFIG=/tmp/kubeconfig - | helm upgrade --install payment-api ./charts/payment-api \\ --namespace payment-api-staging \\ --values ./charts/values-staging.yaml \\ --set image.tag=$CI_COMMIT_SHA \\ --atomic --timeout 5m --wait - rm -f /tmp/kubeconfig environment: ## Name appears in GitLab Environments page name: staging ## URL shown as a link in GitLab url: https://staging.payment.internal ## Auto stop environment after 7 days of inactivity auto_stop_in: 7 days ## Review environments for merge requestsdeploy-review: stage: deploy script: - ./deploy-review.sh $CI_MERGE_REQUEST_IID environment: ## Dynamic name per MR -- creates separate environment per PR name: review/$CI_COMMIT_REF_SLUG url: https://review-$CI_MERGE_REQUEST_IID.payment.internal ## Auto-delete when MR is merged or closed on_stop: stop-review rules: - if: $CI_PIPELINE_SOURCE == 'merge_request_event' stop-review: stage: deploy script: - ./teardown-review.sh $CI_MERGE_REQUEST_IID environment: name: review/$CI_COMMIT_REF_SLUG action: stop rules: - if: $CI_PIPELINE_SOURCE == 'merge_request_event' when: manualMilestone 6 -- Self-hosted runner registration
## Install GitLab Runner on a Linux servercurl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bashsudo apt install gitlab-runner ## Register a runner with Docker executorsudo gitlab-runner register \ --non-interactive \ --url https://gitlab.company.in \ --token REGISTRATION_TOKEN \ --executor docker \ --docker-image alpine:latest \ --description payment-api-runner \ --tag-list docker,payment-vpc \ --docker-volumes /var/run/docker.sock:/var/run/docker.sock ## Verify runner is registeredsudo gitlab-runner statussudo gitlab-runner list ## Use runner tags in jobs to target specific runnersbuild-image: tags: - docker ## only run on runners tagged 'docker' - payment-vpc ## only run on runners in payment VPC ## Runner config file: /etc/gitlab-runner/config.toml## Tune concurrency, resource limits per runnerProduction Best Practices and Common Pitfalls
| Mistake | Problem | Fix |
|---|---|---|
| Using only/except | Deprecated, less powerful | Use rules: blocks always |
| No artifact expiry | Storage fills up | Set expire_in on all artifacts |
| Shared runner for prod deploy | No network access to cluster | Register specific runner in prod VPC |
| No cache key strategy | Cache never invalidates | Key cache on lockfile hash |
Missing when: always on artifacts |
Test results lost on failure | Upload artifacts when: always |
Quick Reference and Troubleshooting Commands
| Task | Command |
|---|---|
| View pipeline | glab ci view |
| View job logs | glab ci trace JOB_ID |
| Retry failed job | glab ci retry JOB_ID |
| Cancel pipeline | glab ci cancel PIPELINE_ID |
| Validate YAML | GitLab UI > CI/CD > CI Lint |
| Trigger via API | curl -X POST -H 'PRIVATE-TOKEN: $TOKEN' URL/pipeline |
| List runners | sudo gitlab-runner list |
PLACEMENT PRO TIP**Tip:** Use GitLab's built-in CI Lint tool (available at `/-/ci/lint` in your project) to validate `.gitlab-ci.yml` before pushing. It shows syntax errors, invalid keywords, and can simulate what pipeline a given push would generate -- invaluable when debugging complex rules.
REMEMBER THIS**Remember:** GitLab CI predefined variables are always available without configuration. `$CI_COMMIT_SHA` is the full commit hash. `$CI_REGISTRY_IMAGE` is the full container registry path for the project. `$CI_DEFAULT_BRANCH` is the name of the default branch (main or master). Use these instead of hardcoding values.
COMMON MISTAKE / WARNING**Security:** Mark CI/CD variables as Protected and Masked in GitLab settings. Protected variables are only available to pipelines on protected branches -- preventing a feature branch pipeline from accessing production credentials. Masked variables are redacted from job logs even if accidentally printed.
COMMON MISTAKE / WARNING**Common Mistake:** Forgetting `GIT_STRATEGY: none` on jobs that do not need source code. By default, every job checks out the entire repository -- even a job that just scans a Docker image. Set `GIT_STRATEGY: none` on jobs that only need artifacts from previous jobs or just run a command against an external resource. This eliminates unnecessary git operations and speeds up those jobs significantly.