How GitLab CI/CD is structured
GitLab CI/CD is configured by a single file at the root of your repo: .gitlab-ci.yml. When you push, GitLab reads it and runs a pipeline made of stages, each containing jobs. Jobs in the same stage run in parallel; stages run in sequence. The mental model:
Pipeline
├── stage: test (jobs run in parallel)
├── stage: build (runs after test passes)
└── stage: deploy (runs after build)If any job in a stage fails, later stages don't run, your gate against shipping broken code.
A first pipeline
Here's a minimal but realistic pipeline for a Node app:
stages:
- test
- build
- deploy
variables:
NODE_ENV: "test"
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
test:
stage: test
image: node:20
script:
- npm ci
- npm test
build:
stage: build
image: node:20
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 weekKey pieces:
imagesets the Docker image the job runs in.cachepersistsnode_modulesbetween runs keyed on the lockfile, so unchanged dependencies aren't reinstalled every time.artifactspass build output (dist/) to later stages and let you download it.
Caching vs. artifacts
These two get confused constantly:
| Cache | Artifacts | |
|---|---|---|
| Purpose | Speed up jobs (deps) | Pass results between stages |
| Lifetime | Best-effort, may be evicted | Stored, downloadable, expiring |
| Example | node_modules/ | dist/, test reports |
Rule of thumb: cache things you can regenerate (dependencies), artifact things you need downstream (build output, reports).
Secrets and variables
Never hardcode credentials in .gitlab-ci.yml. Use GitLab's CI/CD variables (Settings → CI/CD → Variables), which are injected as environment variables into jobs. Mark sensitive ones as masked (hidden in logs) and protected (only available on protected branches).
deploy:
stage: deploy
image: alpine:3
script:
- echo "Deploying with token..."
- ./deploy.sh # reads $DEPLOY_TOKEN from the environment
# $DEPLOY_TOKEN is defined as a masked, protected CI/CD variableEnvironments and deployment gating
GitLab's environment keyword tracks where you deploy and unlocks features like deployment history and manual approval gates. A common pattern: auto-deploy to staging, but require a manual click for production.
deploy_staging:
stage: deploy
script: ./deploy.sh staging
environment:
name: staging
url: https://staging.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy_production:
stage: deploy
script: ./deploy.sh production
environment:
name: production
url: https://example.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual # requires a human to click "play"The when: manual on production means GitLab won't ship to prod until someone explicitly approves, a simple, effective safety gate.
The rules keyword: controlling when jobs run
Modern GitLab pipelines use rules to decide whether a job runs. Common patterns:
rules:
- if: '$CI_COMMIT_BRANCH == "main"' # only on main
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # on MRs
- changes:
- src/**/* # only if src changedrules replaced the older only/except syntax and is far more flexible. Use it to avoid running expensive deploy jobs on every feature branch.
Deploying to PandaStack from GitLab
PandaStack's native model is git-push deploys, connect a repo and pushes build and deploy automatically. If your team's source of truth is GitLab, you have two clean options:
Option 1, let PandaStack watch the repo. Connect your GitLab repo to PandaStack so pushes trigger builds and deploys directly. Your .gitlab-ci.yml then focuses on what GitLab does best, running tests and linting, while PandaStack handles the build-and-deploy. This keeps responsibilities clean: GitLab validates, PandaStack ships.
Option 2, trigger PandaStack from a GitLab job. If you want GitLab to own the deploy step (e.g., to gate prod behind when: manual), have a deploy job call PandaStack, for example by POSTing to a deploy hook after tests pass:
deploy_production:
stage: deploy
image: alpine:3
script:
- apk add --no-cache curl
- curl -X POST "$PANDASTACK_DEPLOY_HOOK" # stored as a masked CI/CD variable
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manualEither way, PandaStack does the heavy lifting on the deploy side: builds run in rootless BuildKit in ephemeral Kubernetes Job pods, images go to Google Artifact Registry, and Helm deploys to GKE, with live build logs, deploy history, and one-click rollbacks. And if your app needs a database, PandaStack auto-wires it by injecting DATABASE_URL, so your GitLab pipeline never has to manage connection strings.
Tips for fast, reliable pipelines
- Cache dependencies keyed on the lockfile, this is the biggest speedup.
- Use
needs:to run jobs out of strict stage order where dependencies allow, shortening total pipeline time. - Fail fast: put quick checks (lint, type-check) early so you don't wait on a long build to learn about a typo.
- Keep secrets masked and protected, and never echo them.
- Pin image tags (
node:20, notnode:latest) for reproducible builds.
Conclusion
GitLab CI/CD gives you a flexible, declarative pipeline: stages and jobs, caching for speed, artifacts for handoff, masked variables for secrets, and environment + when: manual for safe production gating. Pair it with a deployment target that handles the build-and-ship mechanics and you get a pipeline where GitLab validates and your platform deploys.
PandaStack connects to GitLab repos for automatic git-push deploys, or accepts a deploy trigger from a gated GitLab job, with managed builds, live logs, rollbacks, and auto-wired databases. Try it on the free tier at https://dashboard.pandastack.io.
References
- GitLab CI/CD
.gitlab-ci.ymlreference: https://docs.gitlab.com/ee/ci/yaml/ - GitLab CI/CD variables: https://docs.gitlab.com/ee/ci/variables/
- GitLab environments and deployments: https://docs.gitlab.com/ee/ci/environments/
- GitLab
ruleskeyword: https://docs.gitlab.com/ee/ci/yaml/#rules