A GitLab CI pipeline that just runs docker build && docker push is not a CI pipeline — it is a deployment script with a YAML wrapper. A production-ready pipeline builds trust: it tells you whether this change is safe to promote, not just whether it compiles.
Here is the pipeline structure we use after running dozens of CI/CD training sessions.
Pipeline Stages
stages:
- build
- test
- security
- publish
- deploy
Each stage runs only if the previous one passes. This is not obvious to beginners: by default in GitLab CI, all jobs in a stage run in parallel, and the pipeline moves to the next stage only when all jobs in the current stage succeed.
Build: Multi-Stage Docker for Speed
build:image:
stage: build
image: docker:26
services:
- docker:26-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build
--cache-from $CI_REGISTRY_IMAGE:cache
--build-arg BUILDKIT_INLINE_CACHE=1
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
-t $CI_REGISTRY_IMAGE:cache
.
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker push $CI_REGISTRY_IMAGE:cache
rules:
- if: $CI_COMMIT_BRANCH
The --cache-from trick cuts build time from 4 minutes to 40 seconds on a warm cache. The cache tag always points to the latest built image, so the next build has something to pull from.
Test: Unit + Integration in Parallel
test:unit:
stage: test
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
script:
- pytest tests/unit/ -v --junitxml=report.xml
artifacts:
reports:
junit: report.xml
expire_in: 1 week
test:integration:
stage: test
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
services:
- postgres:16-alpine
variables:
DATABASE_URL: postgresql://test:test@postgres/testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
script:
- pytest tests/integration/ -v
JUnit report artifacts are displayed natively in GitLab’s merge request UI. Engineers see which tests failed without reading the pipeline logs.
Security: Scan Before You Push to Production
security:trivy:
stage: security
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image
--exit-code 1
--severity CRITICAL
--no-progress
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
allow_failure: false # Block on CRITICAL CVEs
security:sast:
stage: security
include:
- template: Security/SAST.gitlab-ci.yml
--exit-code 1 with --severity CRITICAL fails the pipeline if a CRITICAL CVE is found in the image. This is the floor — you can tighten it to HIGH,CRITICAL once the team has worked through the initial findings.
Publish: Tag on Merge to Main
publish:latest:
stage: publish
image: docker:26
services:
- docker:26-dind
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:latest
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Deploy: Environment Promotion with Manual Gates
deploy:staging:
stage: deploy
environment:
name: staging
url: https://staging.myapp.com
script:
- kubectl set image deployment/api api=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy:production:
stage: deploy
environment:
name: production
url: https://myapp.com
when: manual # Human clicks the button
script:
- kubectl set image deployment/api api=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
The when: manual gate means production deployments require a human click in the GitLab UI. GitLab’s environment view shows current deployment status per environment — this is what you show in the sprint review.
Training
The CI/CD training at Sphinx-Digital builds exactly this pipeline from scratch over 2 days, starting from a blank .gitlab-ci.yml and ending with a full environment promotion flow including rollback strategies.