GitLearn
⌨️ Practice ⟳ Flow
15px

⚙️ 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:

TermMeaning
PipelineEverything that runs for one push — the whole assembly line. (GitHub calls this a workflow.)
StageA phase of the pipeline: build → test → deploy. Stages run in order; a failed stage stops the pipeline.
JobOne task inside a stage (e.g., "run unit tests"). Jobs in the same stage run in parallel.
git push build test deploy 🌐 environment
💡
If a stage fails, everything after it is skipped — a red pipeline never reaches deploy. That is the entire safety model of CI/CD in one sentence.

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 runnersSelf-hosted runners
Who provides the machinegitlab.com / github.com (Linux VMs by default)You — a server, VM, or even a spare PC
SetupNone — works out of the boxInstall + register the runner program once
When you need self-hostedDeploying 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
💡
GitLab orders work with 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:

TriggerGitLabGitHub Actions
Push to a branchdefault — every pushon: push: branches: [...]
Merge / pull requestmerge request pipelines (rules: if: $CI_PIPELINE_SOURCE == "merge_request_event")on: pull_request
Tag pushrules: if: $CI_COMMIT_TAGon: push: tags: ['v*']
ScheduleCI/CD → Schedules (cron)on: schedule: cron
Manual clickRun pipeline button / when: manual jobson: 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).

build job
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.

💡
"What version is live right now?" — don't ask a teammate, open the Environments dashboard. It answers exactly that, per environment, with the commit SHA.

"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/* branchnothing (build + test only)Unreviewed code never ships
developdev / stagingIntegration testing ground
mainstaging or productionDepends on team — see tag row
tag v1.2.0productionReleases are explicit, auditable events
🔍
How to find the answer in any repo, in 60 seconds: open .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:

1
Re-run a previous deployment. GitLab: Operate → Environments → production → Rollback button next to any past deployment. GitHub: open the last good workflow run → Re-run all jobs. Fastest — no new commit; GitLab redeploys the already-built artifact, GitHub re-runs from the old commit.
2
Deploy an old tag. If releases are tag-based, run the pipeline for v1.1.0 again — same auditable path a normal release takes.
3
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.)
⚠️
Rolling back code does not roll back your database. If the bad release ran schema migrations, redeploying old code against the new schema can make things worse. Check migrations before any rollback.

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
⚠️
If a secret ever lands in a commit, rotate it immediately — deleting the commit is not enough, it lives in history and in every clone. Treat a committed secret as leaked.

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
💡
Notice the two ends of the spectrum: NuGet needs a pipeline, a registry, and credentials. The plugin marketplace needs only 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:

ConceptGitLab CIGitHub Actions
Config file.gitlab-ci.yml (one file, repo root).github/workflows/*.yml (many files)
The whole runPipelineWorkflow run
Orderingstages:needs: between jobs
Unit of workJobJob (contains steps:)
ExecutorRunner (matched by tags:)Runner (matched by runs-on:)
Passing files between jobsartifacts: (auto-downloaded)upload-artifact / download-artifact
Branch conditionrules: if: $CI_COMMIT_BRANCH == "main"on: push: branches: [main]
Tag conditionrules: if: $CI_COMMIT_TAGon: push: tags: ['v*']
Environmentenvironment: name:environment:
Manual gatewhen: manualEnvironment required reviewers
SecretsCI/CD Variables → $MY_SECRETActions secrets → ${{ secrets.MY_SECRET }}
Skip CI for a commit[skip ci] in commit message[skip ci] in commit message
Where deployments showOperate → EnvironmentsRepo 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.

01 — Pipeline failed at the deploy stage
Build and tests green, deploy red. Where to look and what it usually is.
1
Open the job log. Click the failed job — the answer is almost always in the last 20 lines. GitLab: CI/CD → Pipelines → click the red job. GitHub: Actions tab → the run → the red job.
2
Match it to the usual suspects: 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.
3
Fix and retry the job — both platforms have a Retry / Re-run button on the failed job. You don't need a new commit to retry.
02 — Pushed to main but nothing deployed
The pipeline ran — or didn't — but no deploy happened.
1
Did a pipeline start at all? If not: commit message contained [skip ci], or the file has a syntax error (see card 06).
2
Pipeline ran but no deploy job in it → the deploy job's 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.
3
Deploy job exists but is paused → it's a when: manual gate waiting for a click, not a failure.
03 — Job stuck in pending forever
Not red, not green — just spinning.
1
Pending = no runner has picked the job up.
2
Check runner status: GitLab Settings → CI/CD → Runners (green dot?), GitHub Settings → Actions → Runners. Runner offline → start it (.\gitlab-runner.exe start / run.cmd from the Runners & Agents section).
3
Runner online but still pending → tag mismatch: the job's tags:/runs-on: doesn't match any registered runner's tags/labels.
04 — Deployed a broken build to production
Get back to the last good version, fast.
1
Roll back first, debug later: Environments dashboard → Rollback on the last good deployment (GitLab) / Re-run the last good workflow run (GitHub).
2
Then remove the bad code from the branch: git revert <sha> + push, so the next pipeline doesn't re-ship it.
3
Check whether the bad release ran DB migrations before rolling back — see the warning in Deployment Patterns.
05 — Works on staging, fails on production
Same code, different result — it's almost never the code.
1
The code is identical; the environment isn't. Diff the variables: GitLab Settings → CI/CD → Variables — check per-environment scopes; GitHub Settings → Environments — compare each environment's secrets/variables.
2
Usual suspects: a secret defined for staging but missing for production, a protected variable that a non-protected ref can't read, or config pointing at the wrong URL/database.
3
Print (masked) config at deploy startecho "Deploying with API_URL=$API_URL" — so the next diff takes seconds.
06 — Pipeline didn't even start — invalid YAML
You pushed and… silence. The file itself is broken.
1
One bad indent means the platform can't parse the file, so your push produces no jobs — GitLab shows a "yaml invalid" pipeline entry on the Pipelines page; GitHub shows the syntax error on the workflow file in the Actions tab.
2
Catch it earlier next time: GitLab lets you validate before pushing — CI/CD → Editor (validates live) or the CI Lint tool; on GitHub the error only surfaces after the push, so check the Actions tab right after changing a workflow file.
3
YAML gotchas: tabs are illegal (spaces only), : inside an unquoted string breaks parsing, indentation is meaning.