GitLab CI/CD Best Practices: From Beginner to Production-Grade Pipelines
GitLab CI/CD is a core component of the DevOps toolchain. Many teams stop at "it runs": tests all run on one machine, dependencies download from scratch every time, and production deploys depend on someone clicking a button. This article walks from pipeline design to security scanning and gives a production-grade setup you can follow.
Before writing .gitlab-ci.yml, it is worth clarifying a goal: a pipeline is not "chaining commands together" but codifying the verify-build-deliver process into rules so every commit follows the same path and human error has no chance to happen. The configuration below follows a typical Node.js project; swap in your own stack.
Pipeline Structure Design
stages:
- lint
- test
- build
- deploy
variables:
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
lint:
stage: lint
image: node:20
script:
- npm ci
- npm run lint
test:
stage: test
image: node:20
script:
- npm ci
- npm run test:ci
coverage: '/Lines\s*:\s*(\d+\.\d+%)/'
The order of stages is the order of execution — the next stage starts only when the previous one fully passes. Splitting lint, test, build, and deploy pays off in several ways: failures surface early (no need to wait for tests to find a style issue), jobs within a stage can run in parallel, and deploy stands alone so you can add manual confirmation. The coverage regex extracts coverage from test output and shows it in merge request details automatically.
If your project has many modules and complex build dependencies, use the needs keyword to break the strict stage order: a job starts as soon as the jobs it actually depends on finish, further shortening pipeline time. For example, a UI test only needs the build to finish — it should not wait for the backend integration tests.
Passing Build Products (Artifacts)
To pass files between test and build jobs, use artifacts, not cache:
build:
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 week
deploy:
stage: deploy
script:
- ls dist/ && ./deploy.sh
dependencies:
- build
dist/ is packaged in the build stage, passed to deploy via artifacts, and expire_in: 1 week stops the repository from growing without bound. Artifacts are "must exist" deliverables; cache is a "best effort" speedup — the two serve entirely different purposes, so do not mix them. A common mistake is putting files to deploy into cache instead of artifacts: on the next Runner, a cache miss makes ls dist/ in the deploy job fail immediately.
Runners and Variables
Where the pipeline runs and where secrets live are the two easiest places to trip up. Shared runners are free but queue; self-hosted runners need a consistent environment — use the Docker executor so every job runs in a clean container. Put secrets and sensitive variables in CI/CD variables, never as plaintext in .gitlab-ci.yml — see environment variables and secrets management.
Here is a comparison: shared runners suit small-to-medium teams with zero cost but slower pipelines during queues; self-hosted runners suit teams that care about isolation and caching — Docker and build caches live locally, so builds get noticeably faster after the first run. Either way, put sensitive values like SSH private keys and cloud credentials into GitLab CI/CD variables with the "Masked" option enabled so they never leak into logs.
Caching Strategy
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
- .npm/
policy: pull-push
cache and artifact are two easily confused concepts: cache speeds up dependency installs so npm ci is faster; artifacts pass build products to later jobs, such as handing a compiled bundle to a deploy job. The key is per-branch so branches do not pollute each other. Note that cache is best-effort — never store something in it that must exist. policy: pull-push means the pipeline both pulls old cache and writes new cache; if a job only reads dependencies and never writes, use policy: pull to skip the upload step and reduce Runner I/O.
Multi-Environment Deployment
deploy-review:
stage: deploy
only:
- merge_requests
environment:
name: review/$CI_MERGE_REQUEST_IID
url: https://$CI_MERGE_REQUEST_IID.example.com
script:
- ./deploy-review.sh
deploy-production:
stage: deploy
only:
- main
when: manual
environment:
name: production
url: https://example.com
script:
- ./deploy.sh
Each merge request spins up a review environment so designers and product people can see the real page; production deploys add when: manual, so a human confirms before hitting the button — merging alone does not ship. Name environments clearly and GitLab's environments page aggregates deploy records and URLs for every environment. A useful addition is auto-cleanup for review environments: after a merge request closes, an unattended environment keeps consuming resources, so set up a cleanup policy or cron job to reclaim it periodically.
Security Scanning
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
GitLab's built-in security templates plug in with one line: SAST for static code analysis, Dependency-Scanning for vulnerable libraries, and Secret-Detection for keys accidentally committed to the repo. None of them require writing rules yourself — just include the official templates. Enforce these scans at least on the main branch and wire the results into the merge request review flow — a secret caught before merge is far cheaper than remediating a leak after launch.
Frequently Asked Questions
- Does
npm cidownload dependencies every time? Check the cache key and paths; make surenode_modulesis listed under paths. - How should I deploy to a server? Common options are SSH with a deploy key, or pushing to a container registry and letting the server pull — see website CI/CD pipeline setup and Docker Compose production deployment.
- Why is the pipeline always queued? Likely shared runner contention; consider self-hosting a runner or raising the priority of critical jobs.
- No test coverage shown in merge requests? Check that the
coverageregex matches the test output format;test:cioutput needs a line likeLines: 85.5%.
References
Reference: GitLab CI/CD documentation https://docs.gitlab.com/ci/
Reference: GitLab CI/CD variables https://docs.gitlab.com/ci/variables/
Reference: GitLab cache and artifact docs https://docs.gitlab.com/ci/caching/