⚙️ CI/CD & Deployment
You push a commit — and somehow it becomes a running app on a server. This page removes the "somehow": pipelines, the machines that run them, environments, how a push to main knows where to deploy, how to roll back, and how to publish packages. GitLab CI is the main track; every example has a GitHub Actions tab.
What is CI/CD?
Continuous Integration (CI) — every push is automatically built and tested, so broken code is caught in minutes, not at release time. Continuous Delivery/Deployment (CD) — code that passes CI is automatically shipped to an environment.
Three words carry the whole topic:
| Term | Meaning |
|---|---|
| Pipeline | Everything that runs for one push — the whole assembly line. (GitHub calls this a workflow.) |
| Stage | A phase of the pipeline: build → test → deploy. Stages run in order; a failed stage stops the pipeline. |
| Job | One task inside a stage (e.g., "run unit tests"). Jobs in the same stage run in parallel. |
Runners & Agents — the Machines That Do the Work
A pipeline is just YAML until something executes it. That something is a runner (GitLab) / runner agent (GitHub Actions) — a small program on a machine that polls the server, picks up jobs, runs their scripts, and reports results back.
| Shared / hosted runners | Self-hosted runners | |
|---|---|---|
| Who provides the machine | gitlab.com / github.com (Linux VMs by default) | You — a server, VM, or even a spare PC |
| Setup | None — works out of the box | Install + register the runner program once |
| When you need self-hosted | Deploying to internal servers, Windows-only builds, private networks, or when hosted-minute quotas run out | |
Registering a self-hosted runner (Windows):
Get the registration token from your project: Settings → CI/CD → Runners → New project runner. Then, in an Administrator PowerShell:
# download gitlab-runner.exe from docs.gitlab.com, then:
.\gitlab-runner.exe install
.\gitlab-runner.exe register --url https://gitlab.com --token glrt-XXXXXXXX
.\gitlab-runner.exe start
How a job finds its runner: jobs and runners are matched by tags (GitLab) or labels via runs-on: (GitHub). A job tagged windows only runs on a runner registered with the windows tag. If no runner matches, the job waits forever — remember this for the Troubleshooting section.
That's all you need to use runners. Administering them (Docker executors, autoscaling, runner fleets) is its own topic — go deeper in the GitLab Runner docs or the GitHub self-hosted runner docs.
The Pipeline File
The pipeline lives in the repo, versioned like any other file. GitLab reads .gitlab-ci.yml at the repo root; GitHub reads every .yml/.yaml file in .github/workflows/. A complete three-stage pipeline:
stages:
- build
- test
- deploy
build-job:
stage: build
script:
- echo "Compiling the app..."
- mkdir build
- echo "compiled output" > build/app.txt
artifacts:
paths:
- build/ # hand the output to later stages
test-job:
stage: test
script:
- echo "Running tests..."
deploy-job:
stage: deploy
script:
- echo "Copying build/ to the server..."
environment: staging
stages:; GitHub has no stages — jobs declare dependencies with needs:, and the dependency chain is the stage order. Also note: GitHub jobs need an explicit actions/checkout step; GitLab clones the repo automatically.What triggers a pipeline?
Half of all CI/CD confusion is "why did (or didn't) a pipeline start?" These are the trigger types:
| Trigger | GitLab | GitHub Actions |
|---|---|---|
| Push to a branch | default — every push | on: push: branches: [...] |
| Merge / pull request | merge request pipelines (rules: if: $CI_PIPELINE_SOURCE == "merge_request_event") | on: pull_request |
| Tag push | rules: if: $CI_COMMIT_TAG | on: push: tags: ['v*'] |
| Schedule | CI/CD → Schedules (cron) | on: schedule: cron |
| Manual click | Run pipeline button / when: manual jobs | on: workflow_dispatch |
How the deploy job gets the build output
Jobs are isolated — each may run on a different runner, starting from a fresh clone. Your compiled output does not magically survive between jobs. The bridge is artifacts: the build job uploads build/ to the server, and later jobs download it automatically (GitLab) or via download-artifact (GitHub).
uploads build/→ 📦 artifact store
on the CI server→ deploy job
downloads build/
Environments — dev, staging, production
An environment is a named place your app runs: dev, staging, production. Both platforms treat environments as first-class objects — declare one on a deploy job and the platform starts tracking which commit is running where.
deploy-job:
stage: deploy
script:
- echo "Deploying..."
environment:
name: staging
url: https://staging.example.com # link shown in the UI
Dashboard: Operate → Environments — every environment, what's deployed there, when, by which pipeline, with Re-deploy and Rollback buttons.
"I pushed to main — which environment gets deployed?"
The question this page exists to answer. The short version: Git has no idea. A push just moves a branch pointer. The branch→environment mapping is written by your team, in the pipeline file, as rules on the deploy jobs:
deploy-staging:
stage: deploy
script:
- echo "Deploying to STAGING"
environment: staging
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
deploy-production:
stage: deploy
script:
- echo "Deploying to PRODUCTION"
environment: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
Push to develop → only deploy-staging appears in the pipeline. Push to main → only deploy-production. Jobs whose rules don't match simply don't exist in that pipeline.
Common team conventions (yours may differ — the pipeline file is the truth):
| You push to… | Typically deploys to… | Why |
|---|---|---|
feature/* branch | nothing (build + test only) | Unreviewed code never ships |
develop | dev / staging | Integration testing ground |
main | staging or production | Depends on team — see tag row |
tag v1.2.0 | production | Releases are explicit, auditable events |
.gitlab-ci.yml (or .github/workflows/), find the jobs with an environment: key, read their rules: / branches: conditions. That's the map. Cross-check against the Environments dashboard to see where the last deploys actually went.Deployment Patterns
Auto-deploy vs manual gate
Staging usually deploys automatically. Production usually waits for a human to press a button:
deploy-production:
stage: deploy
script:
- echo "Deploying to PRODUCTION"
environment: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual # pipeline pauses; a ▶ button appears in the UI
Tag-based releases
Many teams deploy production only from a version tag, never from a branch — releases become explicit, numbered, auditable events:
deploy-production:
stage: deploy
script:
- echo "Deploying $CI_COMMIT_TAG to PRODUCTION"
environment: production
rules:
- if: '$CI_COMMIT_TAG =~ /^v/' # only runs for tags like v1.2.0
Releasing then becomes: git tag v1.2.0 && git push origin v1.2.0. (Tags themselves are covered in the GitLab Platform guide.)
Deploying an old version (rollback)
New build is broken in production? Three ways back, in order of preference:
v1.1.0 again — same auditable path a normal release takes.git revert + push. Reverts the bad commit on main; the normal pipeline redeploys the old state. Slowest, but history shows exactly what happened and why — best when the bad code must also be removed from the branch. (See Mistakes & Fixes for revert details.)Who is allowed to deploy?
Two locks, usually combined: protected branches control who can push/merge to main (covered in Branch Protection), and protected environments control who can trigger a deploy job targeting production — GitLab: Settings → CI/CD → Protected environments; GitHub: Settings → Environments → required reviewers + deployment branch rules. If you can't press the production ▶ button, this is why — ask a maintainer, don't work around it.
Secrets & Variables
Real deploys need credentials — server passwords, API keys, tokens. They must never be committed to the repo (see the .gitignore guide). Instead, store them on the platform and the runner injects them as environment variables at job time:
Settings → CI/CD → Variables → Add variable. Two checkboxes matter:
- Mask variable — value is hidden as
[MASKED]in job logs - Protect variable — only available to pipelines on protected branches/tags (so a feature-branch pipeline can't read your production key)
deploy-job:
script:
- ./deploy.sh --api-key "$DEPLOY_API_KEY" # injected, never in the repo
Shipping Packages, Not Just Deployments
Sometimes the pipeline's product isn't a running app — it's a package other people install. Same machinery, different last step.
Repo → NuGet package
A library repo can publish itself to a NuGet feed on every version tag. (This is the one .NET-specific example on this page — the pattern is identical for npm, PyPI, and Maven: pack on tag, push to registry with a stored API key.)
publish-nuget:
stage: deploy
rules:
- if: '$CI_COMMIT_TAG =~ /^v/'
script:
- dotnet pack -c Release -o out
- dotnet nuget push "out/*.nupkg"
--source "$NUGET_FEED_URL"
--api-key "$NUGET_API_KEY"
Feed options: nuget.org, or GitLab's built-in Package Registry (Deploy → Package Registry — every project has one, great for internal packages).
Consumers then just run dotnet add package YourLibrary — your repo has become a product with releases.
Repo → AI plugin marketplace
The opposite extreme, and a lesson about Git itself: for AI coding tools like Claude Code, a plain Git repo is the distribution channel. No pipeline, no registry, no build — pushing is publishing, because consumers install straight from the git URL.
A marketplace is a repo with one JSON manifest and a folder per plugin:
team-marketplace/
├── .claude-plugin/
│ └── marketplace.json # the catalog
└── plugins/
└── commit-helper/
├── .claude-plugin/plugin.json
├── commands/ # slash commands
├── agents/ # custom agents
└── skills/ # skills
// .claude-plugin/marketplace.json
{
"name": "team-marketplace",
"owner": { "name": "Your Team" },
"plugins": [
{
"name": "commit-helper",
"source": "./plugins/commit-helper",
"description": "Team conventions for commits and MRs"
}
]
}
Anyone on the team installs it with the repo URL:
/plugin marketplace add https://gitlab.com/your-team/team-marketplace.git
/plugin install commit-helper@team-marketplace
git push — version control and distribution are the same operation. Everything on this page lives between those two.GitLab ↔ GitHub Actions Reference
Same concepts, different vocabulary:
| Concept | GitLab CI | GitHub Actions |
|---|---|---|
| Config file | .gitlab-ci.yml (one file, repo root) | .github/workflows/*.yml (many files) |
| The whole run | Pipeline | Workflow run |
| Ordering | stages: | needs: between jobs |
| Unit of work | Job | Job (contains steps:) |
| Executor | Runner (matched by tags:) | Runner (matched by runs-on:) |
| Passing files between jobs | artifacts: (auto-downloaded) | upload-artifact / download-artifact |
| Branch condition | rules: if: $CI_COMMIT_BRANCH == "main" | on: push: branches: [main] |
| Tag condition | rules: if: $CI_COMMIT_TAG | on: push: tags: ['v*'] |
| Environment | environment: name: | environment: |
| Manual gate | when: manual | Environment required reviewers |
| Secrets | CI/CD Variables → $MY_SECRET | Actions secrets → ${{ secrets.MY_SECRET }} |
| Skip CI for a commit | [skip ci] in commit message | [skip ci] in commit message |
| Where deployments show | Operate → Environments | Repo sidebar → Environments |
Deep dives: GitLab CI/CD docs · GitHub Actions docs.
Troubleshooting — When the Deploy Goes Wrong
Six situations every team hits. Click a card to expand it.
401/403 → missing, expired, or unprotected-branch-blocked secret (Section: Secrets & Variables). connection refused / timeout → target server down or unreachable from the runner. command not found → the deploy tool isn't installed on the runner.[skip ci], or the file has a syntax error (see card 06).rules:/branches: didn't match your branch — re-read Section "Push to main → Which Env?"; you may have pushed to a branch with no mapping.when: manual gate waiting for a click, not a failure..\gitlab-runner.exe start / run.cmd from the Runners & Agents section).tags:/runs-on: doesn't match any registered runner's tags/labels.git revert <sha> + push, so the next pipeline doesn't re-ship it.echo "Deploying with API_URL=$API_URL" — so the next diff takes seconds.: inside an unquoted string breaks parsing, indentation is meaning.