Designs by Duhart All writing

·8 min read·cicd · devops · gitlab · continuousdelivery · softwareengineering · backend · platformengineering · codinginterview · automation · programming

CI/CD Pipelines: What I Would Explain in an Interview

CI versus continuous delivery versus continuous deployment, the stages of a real pipeline, cache versus artifacts, environments and approvals, blue/green and canary, secrets, and a working .gitlab-ci.yml.

Cover slide: CI/CD Pipelines: What I Would Explain in an Interview. Bottom left, an "Interviewing at" badge with the logos of Google, Amazon, Netflix, Cloudflare.

CI/CD is three ideas wearing one acronym

When an interviewer asks about CI/CD, they usually want to hear whether you know where one idea stops and the next one starts. Most people answer with tools. I'd start with the question every pipeline answers: what happens to a commit after it lands on main?

Continuous integration means everyone merges small changes often, and every change is built and tested automatically. That's it. CI says nothing about production.

Continuous delivery means main is always in a releasable state. Every green commit produces something you could ship, and shipping it is one decision, often one click.

Continuous deployment removes the decision. Every green commit goes to production on its own. That only works when your tests, your monitoring and your rollback are good enough to stand in for the person who used to press the button.

All of my repos live on GitLab, so the examples here are GitLab CI. The ideas carry over to GitHub Actions, Jenkins or anything else; the keywords change, the reasoning doesn't. The carousel slides are in the article too, so you can save this and skim.

CI/CD concepts to know cold

Cover slide: CI/CD Pipelines: What I Would Explain in an Interview. Bottom left, an "Interviewing at" badge with the logos of Google, Amazon, Netflix, Cloudflare.
The carousel this article expands on: eight concepts and one pipeline file.

CI/CD in 40 seconds

The video version of concept 1: what happens to a green commit on main.
Opening frame of the explainer video. A 40 second explainer in the house dark style with burned-in captions: a green pipeline and a site that did not change, then CI, continuous delivery and continuous deployment explained one card each (production not touched, one click, automatic), a joke about who deployed on Friday at 5pm, and an end card reading Save this, designsbyduhart.org.
The video version of concept 1: what happens to a green commit on main. Watch the video: https://designsbyduhart.org/blog/cicd-pipelines/

CI, delivery, deployment

Slide 2 of 10: THREE THINGS, ONE ACRONYM. The question is always the same: what happens to a green commit on main? Why it matters: Continuous delivery keeps a human decision. Continuous deployment removes it, so your tests and monitoring have to be good enough to stand in for that person. A table: , GREEN MAIN IS, TO PROD; CI, built and tested, NO; DELIVERY, always releasable, one click; DEPLOYMENT, shipped, AUTOMATIC. Interview trap: "We do CD" means nothing until you say which one. Ask who or what presses the button.
What happens to a green commit on main decides which one you are doing.

The stages, and why the order matters

A pipeline is a series of stages, and the order is about cost. You want the cheapest check that can fail to run first. A lint error should fail in twenty seconds, not after a ten minute build.

  1. Lint: formatting, static analysis, type checks.
  2. Test: unit tests, then integration tests, with JUnit reports so failures show up in the merge request.
  3. Build: produce one artifact, once. A container image, a bundle, a binary.
  4. Scan: static analysis for security issues, dependency vulnerabilities, secrets committed by accident.
  5. Deploy: staging first, then production.
  6. Verify: hit the deployed thing and check it's alive and it's the version you meant.
  7. Roll back: the way back, which you rehearse before you need it.

The rule I'd say out loud in an interview is build once, promote everywhere. The artifact you tested is the artifact you deploy. If the production job rebuilds from source, you are shipping a binary nobody tested, built with whatever dependency versions resolved that minute.

Seven stages

Slide 3 of 10: THE STAGES, IN ORDER. Cheap and fast checks first, so a typo fails in seconds, not after a ten minute build. Lint: style and static checks. Test: unit and integration, with reports. Build: one artifact, built once. Scan: SAST, dependencies, leaked secrets. Deploy: staging first, then production. Verify: smoke test the live deploy. Roll back: redeploy the last good artifact. The rule: Build once, promote everywhere. If production rebuilds from source, you are shipping something you never tested.
Fast checks first. One build, promoted through every environment.

The whole pipeline, in one file

In GitLab the pipeline is .gitlab-ci.yml at the root of the repo. Here's a complete, minimal one for a Node service. It's close to what I actually run, with the deploy script reduced to a placeholder you'd swap for your own.

A few choices worth explaining before you read it:

  • workflow: rules runs the pipeline for merge requests and for the default branch only, so you don't get a duplicate branch pipeline for every MR push.
  • The scanners come from GitLab's own templates through include. They add their jobs to the test stage, so they run in parallel with the tests.
  • The Node setup lives in a hidden job, .node, that other jobs extends. I used to put it under default:, and default: applies to every job, including the scanner jobs, which run in images that don't have npm.
  • build keeps dist/ as an artifact. Both deploy jobs needs: [build], so they download that exact output instead of building again.

A complete .gitlab-ci.yml for a Node service: lint, test, scan, build once, deploy to staging, gate production, verify each deploy.

yaml
stages: [lint, test, build, staging, production]

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

include:
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml

.node:
  image: node:22-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [.npm/]
  before_script:
    - npm ci --cache .npm --prefer-offline

lint:
  extends: .node
  stage: lint
  script: npm run lint

test:
  extends: .node
  stage: test
  script: npm test
  artifacts:
    when: always
    reports:
      junit: junit.xml

build:
  extends: .node
  stage: build
  script: npm run build
  artifacts:
    paths: [dist/]
    expire_in: 1 week

.deploy:
  image: alpine:3.20
  needs: [build]
  before_script:
    - apk add --no-cache curl
  script:
    - ./scripts/deploy.sh "$CI_ENVIRONMENT_NAME" dist/
    # verify: the live service must answer, and must be this commit
    - curl --fail --retry 5 --retry-all-errors "$CI_ENVIRONMENT_URL/healthz"
    - test "$(curl -s "$CI_ENVIRONMENT_URL/version")" = "$CI_COMMIT_SHA"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy_staging:
  extends: .deploy
  stage: staging
  environment:
    name: staging
    url: https://staging.example.com
  resource_group: staging

deploy_production:
  extends: .deploy
  stage: production
  needs: [build, deploy_staging]
  environment:
    name: production
    url: https://example.com
  resource_group: production
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

The pipeline is a file

Slide 4 of 10: THE PIPELINE IS A FILE. A code card (yaml) shows: stages: [lint, test, build, staging, production]  include:  # scanners join the test stage - template: Jobs/SAST.gitlab-ci.yml - template: Jobs/Secret-Detection.gitlab-ci.yml  .node: image: node:22-alpine before_script: [npm ci --cache .npm]  lint: extends: .node stage: lint script: npm run lint  test: extends: .node stage: test script: npm test Interview trap: Put npm ci under default: and it runs in the scanner jobs too, which have no npm. A hidden .node job you extend avoids that.
The first half of the file on one slide, and the default: trap.

One thing to check on your own GitLab version: depending on the release, the security templates may only run in branch pipelines unless you opt them into merge request pipelines. Open a merge request and look for the sast and secret_detection jobs before you assume they're there.

Cache versus artifacts

This is my favourite pipeline interview question because it sounds like trivia and isn't.

A cache is for speed. It stores things you could rebuild, like the npm download cache, so the next job or the next pipeline doesn't fetch them again. GitLab treats it as best effort. A job on a different runner may not get it, and your job has to work anyway. Keying it on the lockfile (cache:key:files) means it's thrown away exactly when dependencies change.

An artifact is for correctness. It's the output of a job that later jobs in the same pipeline depend on: the build, the test report, the coverage file. Artifacts are uploaded, guaranteed to be there for jobs that need them, downloadable from the UI, and they expire when you tell them to.

The bug I see is passing dist/ through the cache. It works on a single runner for months, and then the team adds a second runner and deploys start shipping an empty folder.

Cache versus artifacts

Slide 5 of 10: CACHE VS ARTIFACTS. Cache makes jobs faster. Artifacts carry results to later jobs. Do not mix them up. A code card (yaml) shows: .node: cache: key: files: [package-lock.json] paths: [.npm/]  build: extends: .node stage: build script: npm run build artifacts: paths: [dist/] expire_in: 1 week Interview trap: The cache is best effort and may be missing on another runner. Never pass build output through it. That is what artifacts are for.
Cache is best effort and keyed on the lockfile. Artifacts are the contract between jobs.

Environments, gates and approvals

An environment in GitLab is a named deploy target (staging, production) with a URL and a history of every deployment to it. Declaring environment: on a job is what gives you that history, and it's what makes the Rollback button possible later.

The gate is when: manual. Staging deploys on every merge to main; production sits as a play button until someone presses it. That's continuous delivery. Delete the when: manual line and you've switched to continuous deployment, which is a one line change with a very large blast radius.

Two more keywords earn their place. resource_group makes sure only one deploy to an environment runs at a time, so two merges a minute apart can't race each other. And on GitLab Premium or Ultimate you can mark production as a protected environment with required approvals, so the play button needs a named approver and not just anyone with developer access.

Environments and gates

Slide 6 of 10: ENVIRONMENTS AND GATES. Staging deploys on every merge. Production waits for a person. resourcegroup: one deploy at a time. Protected environments add required approvers. A code card (yaml) shows: deploy_production: extends: .deploy stage: production needs: [build, deploy_staging] environment: name: production url: https://example.com resource_group: production rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual
Staging deploys itself. Production waits for a person, one deploy at a time.

Blue/green and canary

Both are ways to avoid sending all of your traffic to a version you've never seen in production.

Blue/green runs two full copies of production. Blue is live. You deploy to green, test it, then flip the router so all traffic goes to green. Rollback is flipping it back, which takes seconds. The cost is running two full stacks, at least for the switchover window.

Canary sends a small slice of traffic to the new version, say 1 percent, then 10, then all of it, and watches error rate and latency at each step. If the numbers go bad, you drain the canary and almost nobody noticed. It's cheaper than blue/green and it catches problems that only show up under real traffic, but it needs good metrics and a way to split traffic.

The trap in both: neither one rolls back your database. If the new version renames a column, the old version can't run against the new schema, and your instant rollback isn't instant anymore. The pattern that fixes it is expand and contract: add the new column, deploy code that writes both, backfill, switch reads, and only drop the old column in a later release.

Blue/green versus canary

Slide 7 of 10: BLUE/GREEN VS CANARY. Two ways to ship without betting all your traffic on one deploy. A table: , BLUE/GREEN, CANARY; TRAFFIC, all at once, 1%, 10%, 100%; UNDO, flip the router, drain the canary; COST, two full stacks, a few servers; CATCHES, broken deploys, bad at scale. Interview trap: Neither one rolls back your database. Make schema changes backward compatible first (expand, then contract), or the old version cannot run.
Flip the router, or creep the traffic. Neither one undoes a schema change.

Secrets

Secrets never go in the repo, and they never go in .gitlab-ci.yml. In GitLab they're CI/CD variables in the project or group settings. Two flags matter:

  • Masked hides the value in job logs if something prints it.
  • Protected only exposes the variable to pipelines on protected branches and tags, so a feature branch can't read the production deploy key.

The better answer, if your cloud supports it, is to store no long-lived key at all. GitLab's id_tokens keyword gives the job a signed, short-lived OIDC token. Your cloud provider trusts GitLab as an identity provider and trades that token for credentials that expire in minutes, scoped to that project and branch.

OIDC instead of a stored key: the job receives a signed token and exchanges it for short-lived cloud credentials.

yaml
deploy_production:
  extends: .deploy
  id_tokens:
    CLOUD_OIDC_TOKEN:
      aud: https://sts.example.com
  script:
    - ./scripts/assume-role.sh "$CLOUD_OIDC_TOKEN"
    - ./scripts/deploy.sh production dist/

Secrets

Slide 8 of 10: SECRETS NEVER LIVE IN GIT. Store them as CI/CD variables: masked hides them in logs, protected limits them to protected branches. Why it matters: Better still, no stored key at all. The job gets a short lived OIDC token and trades it for cloud credentials that expire in minutes. A code card (yaml) shows: deploy_production: id_tokens: CLOUD_OIDC_TOKEN: aud: https://sts.example.com script: - ./assume-role.sh "$CLOUD_OIDC_TOKEN" - ./deploy.sh production From my own pipeline: My site pipeline failed every deploy for weeks while build and test stayed green. The deploy token had never been added as a variable.
Masked and protected variables, or better, no stored key at all.

Here's my own lesson on this one. My portfolio site's pipeline had test, build and deploy stages, and for weeks every push to main showed green builds and tests. The deploy job failed every time with one line: wrangler needed a CLOUDFLARE_API_TOKEN and there wasn't one, because I'd never added it to the project's variables. The pipeline file even documented the requirement in its header. Nothing was wrong with the code. The site just wasn't updating.

Verify, then keep a way back

That story is why I think verify is the most underrated stage. A green pipeline tells you the jobs exited zero. It doesn't tell you production changed.

On another project, the deploy job now does one more thing after deploying: it asks the platform which commit is live and fails unless that equals CI_COMMIT_SHA. That catches a deploy that ran and silently shipped the wrong thing. It can't catch a deploy that never ran. I learned that the day my GitLab CI minutes ran out: the merge looked fine, the deploy job failed with a quota error, nobody got paged, and production stayed on the old commit. So I also check from outside the pipeline, comparing the live version with the head of main. A pipeline can't report on itself when it isn't running.

Rollback is the other half. If every deploy promotes an artifact into a named environment, rolling back is redeploying the previous one, and GitLab's environment page has a button that re-runs an earlier deployment job. Rehearse it once, on a quiet day, so the first time isn't during an incident.

Verify, then keep a way back

Slide 9 of 10: VERIFY IT, THEN KEEP A WAY BACK. A green pipeline is not proof that production changed. Smoke test the real URL after every deploy. Check the live version matches CICOMMITSHA. Watch error rate and latency, not just exit codes. Roll back by redeploying the last good artifact. GitLab keeps every deployment per environment. A code card (VERIFY STEP) shows: # last step of every deploy job live=$(curl -s "$CI_ENVIRONMENT_URL/version") test "$live" = "$CI_COMMIT_SHA" Interview trap: When my CI minutes ran out, the deploy failed and nothing told me. A check inside the pipeline cannot catch a pipeline that never ran.
Smoke test the real URL, check the live version, and redeploy the last good artifact to undo.

How I'd answer in an interview

If someone asks "walk me through your CI/CD pipeline", I'd give them the shape in one breath: lint, test and scan on every merge request; build one artifact on main; deploy it to staging automatically; production behind a manual gate with one deploy at a time; a smoke test and version check after every deploy; rollback by redeploying the last good artifact. Then I'd pick one thing that went wrong and what I changed. Interviewers remember the failure story far longer than the diagram.

Save it for your next interview

Closing slide 10 of 10: Save this. Recap: CI tests it, delivery readies it, deployment ships it; Fast checks first, build once, promote; The pipeline lives in .gitlab-ci.yml; Cache for speed, artifacts for results; Production waits behind a gate; Blue/green flips, canary creeps; Masked, protected, or better, OIDC; Verify the deploy, keep a way back. Your turn: What is the worst thing a green pipeline ever hid from you? Full write-up with code at designsbyduhart.org.
The recap card.

Next in the interview series: why we need MCP, the protocol that lets AI apps talk to your tools.

More: LinkedIn · Instagram. Portfolio and case studies: designsbyduhart.org.

If any of this saved you an afternoon, Buy me a coffee.