CI/CD Interview Questions and Answers
20 hand-picked CI/CD interview questions with
detailed answers. Open the interactive version above to search, filter
by difficulty, run code, bookmark questions and track your progress.
Continuous Integration vs Continuous Delivery vs Continuous Deployment.
Three stacked practices — each builds on the previous one:
- Continuous Integration (CI) — every developer merges to the mainline frequently (at least daily), and every push triggers an automated build + test. Goal: catch integration bugs within minutes, not at the end of a sprint.
- Continuous Delivery (CD) — every commit that passes CI produces a deployable artifact and is automatically pushed through environments up to a production-ready state. The final release to production is a manual button press (business decision).
- Continuous Deployment (CD) — same as above but there is no manual gate: if all checks pass, it goes to production automatically.
So the only difference between the two CDs is whether a human approves the last step.
Commit → Build → Unit tests → Package → Deploy DEV → Integration tests
→ Deploy STAGE → E2E/perf → [manual approval] → PROD ← Continuous DELIVERY
→ Deploy STAGE → E2E/perf → → PROD ← Continuous DEPLOYMENT
Walk me through the stages of a typical production CI/CD pipeline.
A mature pipeline is ordered fastest-and-cheapest-fails-first, so bad commits die in seconds, not 40 minutes:
- Checkout / trigger — webhook on push, PR, tag, or schedule.
- Static checks — lint, format, secret scanning. Seconds.
- Build / compile — produce the artifact once.
- Unit tests + coverage.
- Quality gate — SonarQube on new code, coverage threshold.
- Package — build the container image / jar, tag it with the commit SHA.
- Security scan — image scan (Trivy/Grype), dependency scan (SCA).
- Publish — push the artifact to a registry (ECR, Nexus, Artifactory).
- Deploy to DEV → smoke tests.
- Deploy to STAGE → integration / E2E / performance tests.
- Approval gate (for Continuous Delivery).
- Deploy to PROD — rolling/canary → post-deploy health checks → auto-rollback on failure.
stages:
- lint
- build
- test
- scan
- publish
- deploy-dev
- deploy-stage
- deploy-prod
What does "build once, deploy many" mean, and why does it matter?
You build the artifact (jar, container image, bundle) exactly once, publish it with an immutable identifier, and then promote that same binary through dev → stage → prod. Only configuration changes per environment, injected at deploy time via env vars / ConfigMaps / secret stores.
The anti-pattern is rebuilding from source at each environment. That means:
- Prod runs a binary that was never tested — a transitive dependency could have changed between builds.
- Builds are non-reproducible, so "it passed in stage" proves nothing.
- You waste build minutes three times over.
Practical rules: tag the image with the immutable commit SHA (not just latest), never mutate a published tag, and keep config strictly outside the artifact (12-factor).
# build once
docker build -t myapp:$GIT_SHA .
docker push registry/myapp:$GIT_SHA
# promote the SAME digest — no rebuild
kubectl set image deploy/myapp app=registry/myapp:$GIT_SHA -n dev
kubectl set image deploy/myapp app=registry/myapp:$GIT_SHA -n stage
kubectl set image deploy/myapp app=registry/myapp:$GIT_SHA -n prod
Explain Jenkins architecture — controller, agents, and executors.
Jenkins uses a controller (master) / agent (node) model:
- Controller — serves the UI, stores job config and build history, schedules work, and manages plugins. It should not run builds itself in production.
- Agent — a separate machine/container that actually executes the build steps. Connected via SSH, JNLP/inbound, or dynamically provisioned (Kubernetes plugin, EC2 plugin, Docker).
- Executor — a build slot on an agent. An agent with 4 executors runs 4 builds concurrently.
- Labels — tags on agents (
linux, docker, windows) that a pipeline requests via agent { label 'docker' }.
Modern setups use ephemeral agents: the Kubernetes plugin spins up a fresh pod per build and destroys it after — clean workspace every time, no state leakage, scales to zero.
pipeline {
agent {
kubernetes {
yaml '''
spec:
containers:
- name: maven
image: maven:3.9-eclipse-temurin-21
command: ["cat"]
tty: true
'''
}
}
stages {
stage('Build') {
steps { container('maven') { sh 'mvn -B clean package' } }
}
}
}
Declarative vs Scripted Jenkins pipeline — and what is a shared library?
Declarative — the modern, opinionated syntax wrapped in a pipeline { } block with fixed sections (agent, stages, post, environment). Validated up front, easier to read, works with the Blue Ocean editor. Use this by default.
Scripted — raw Groovy starting with node { }. Full programmatic power (loops, complex conditionals) but no guardrails and harder to maintain. Escape hatch: you can drop into scripted inside declarative using a script { } block.
Shared library — a separate Git repo of reusable Groovy code (vars/ for custom steps, src/ for classes) loaded with @Library('my-lib'). This is how you stop copy-pasting the same 200-line Jenkinsfile into 40 repos — each repo's Jenkinsfile becomes a few lines calling a standard template.
@Library('platform-pipelines@v2') _
pipeline {
agent { label 'docker' }
options { timeout(time: 30, unit: 'MINUTES'); disableConcurrentBuilds() }
environment { IMAGE = "registry/myapp:${env.GIT_COMMIT.take(7)}" }
stages {
stage('Build') { steps { sh 'mvn -B clean package' } }
stage('Test') { steps { sh 'mvn test' }
post { always { junit 'target/surefire-reports/*.xml' } } }
stage('Image') { steps { sh "docker build -t $IMAGE ." } }
stage('Deploy') { when { branch 'main' } steps { deployToK8s(image: env.IMAGE) } }
}
post {
failure { slackSend channel: '#alerts', message: "FAILED ${env.JOB_NAME} #${env.BUILD_NUMBER}" }
always { cleanWs() }
}
}
Explain GitHub Actions: workflows, jobs, steps, and runners.
Hierarchy, largest to smallest:
- Workflow — a YAML file in
.github/workflows/ triggered by an event (push, pull_request, schedule, workflow_dispatch, release). - Job — a set of steps running on one runner. Jobs run in parallel by default; use
needs: to sequence them. - Step — either a shell command (
run:) or a reusable action (uses:). - Runner — the machine. GitHub-hosted (fresh VM per job, billed per minute) or self-hosted (your infra — needed for private network access, special hardware, or cost at scale).
Key mechanics: each job gets a fresh workspace, so passing data between jobs needs actions/upload-artifact or job outputs. Secrets come from repo/org/environment secrets, and for cloud auth you should use OIDC federation instead of long-lived access keys.
name: CI
on:
push: { branches: [main] }
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix: { node: [18, 20, 22] }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '${{ matrix.node }}', cache: 'npm' }
- run: npm ci
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read } # OIDC
environment: production # approval gate
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-south-1
- run: ./deploy.sh
How does GitLab CI work? Explain .gitlab-ci.yml, stages, and runners.
GitLab CI is configured by a single .gitlab-ci.yml at the repo root:
- stages — an ordered list; all jobs in a stage run in parallel, and the next stage starts only when the previous one fully succeeds.
- job — a named block with
stage, script, and optional rules, artifacts, cache, needs. - runner — the agent that picks up jobs, registered with tags. Executors:
docker (most common), shell, kubernetes. - rules / only-except — control when a job runs (branch, tag, MR, changed files, manual).
- needs — creates a DAG so a job can start before its whole stage is done, cutting pipeline time.
GitLab also gives you environments (with deployment history + one-click rollback), protected variables for secrets, and built-in container registry.
stages: [build, test, deploy]
variables:
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
build:
stage: build
script:
- docker build -t $IMAGE .
- docker push $IMAGE
artifacts:
paths: [dist/]
expire_in: 1 week
unit-test:
stage: test
cache:
key: { files: [package-lock.json] }
paths: [node_modules/]
script: [npm ci, npm test]
deploy-prod:
stage: deploy
environment: { name: production, url: https://app.example.com }
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual # approval gate
script: ./deploy.sh $IMAGE
How do you manage secrets in a CI/CD pipeline?
The rule: secrets never live in Git, never live in the image, and never get echoed to logs. In order of maturity:
- Native CI secret store — Jenkins Credentials, GitHub/GitLab secrets, masked and injected as env vars at runtime. Scope them per environment, not globally.
- Dedicated secret manager — HashiCorp Vault, AWS Secrets Manager, Azure Key Vault. The pipeline authenticates and fetches at deploy time, so rotation is central and audited.
- OIDC / workload identity — the best option for cloud access: the pipeline exchanges a short-lived signed token for a cloud role. No stored long-lived credentials at all.
Supporting controls: secret scanning (gitleaks, GitHub push protection) to block commits, masking in log output, short TTLs and rotation, and separate credentials per environment so a dev leak can't touch prod.
# Jenkins — bind a credential only for the block that needs it
withCredentials([string(credentialsId: 'prod-db-pass', variable: 'DB_PASS')]) {
sh 'psql -h $DB_HOST -U app' # note: single quotes, so Groovy never interpolates
}
# Vault fetch at deploy time
export VAULT_TOKEN=$(vault write -field=token auth/jwt/login role=ci jwt=$CI_JOB_JWT)
DB_PASS=$(vault kv get -field=password secret/prod/db)
What is an artifact repository, and how do you version artifacts?
An artifact repository (Nexus, JFrog Artifactory, ECR/GCR/ACR, GitHub Packages) is the system of record for build outputs — jars, npm packages, container images, Helm charts.
Why not just rebuild from source? Because the repository gives you immutability (a published version can never change), traceability (which build, which commit), caching of upstream dependencies (proxy remote registries so builds don't break when npm is down), and a security choke point for scanning and license policy.
Versioning in practice:
- SemVer
MAJOR.MINOR.PATCH for anything consumed by others — MAJOR = breaking, MINOR = backwards-compatible feature, PATCH = fix. - Immutable build identity for deployables —
1.4.2-a3f9c1 or just the commit SHA. - SNAPSHOT / pre-release versions for in-progress builds, kept out of prod and expired aggressively.
- Never overwrite a released version, and never deploy
latest to production.
# tag with both a human version and the immutable SHA
docker build -t myapp:1.4.2 -t myapp:1.4.2-$GIT_SHA .
# deploy by DIGEST for absolute immutability (tags can be re-pointed)
kubectl set image deploy/myapp \
app=registry/myapp@sha256:9f2a...c41b
Compare rolling, blue-green, canary, and feature-flag deployments.
| Strategy | How it works | Cost / risk |
|---|
| Recreate | Stop old, start new | Downtime. Only for batch/internal apps. |
| Rolling | Replace instances a few at a time | No extra infra, no downtime — but both versions run at once, and rollback is a slow reverse-roll. |
| Blue-green | Two full environments; flip the load balancer to the new one | Instant rollback (flip back), but 2× infrastructure and DB migrations must be compatible with both. |
| Canary | Route 1% → 10% → 50% → 100% of traffic to the new version, watching metrics at each step | Smallest blast radius; needs good metrics + traffic-splitting (Ingress, service mesh, Argo Rollouts). |
| Feature flags | Ship the code dark, enable per user/segment at runtime | Decouples deploy from release; instant kill switch. Cost: flag debt if never cleaned up. |
# Argo Rollouts canary with automated analysis
strategy:
canary:
steps:
- setWeight: 10
- pause: { duration: 5m }
- analysis: # abort if error-rate SLO breaks
templates: [{ templateName: error-rate }]
- setWeight: 50
- pause: { duration: 10m }
- setWeight: 100
A deployment breaks production. Walk me through your rollback.
Priority order: stop the bleeding first, diagnose second.
- Detect — post-deploy health checks / alerts fire (error rate, latency, 5xx). Ideally automated, not a customer ticket.
- Decide fast — if user impact is ongoing, roll back. Do not debug a live outage in place.
- Roll back — redeploy the previous known-good immutable artifact:
kubectl rollout undo, flip blue-green back, or set canary weight to 0. This is why "build once" matters: the old artifact still exists and is trusted. - Verify — confirm metrics recover; announce in the incident channel.
- Contain the data problem — if a DB migration was involved, rollback may not be possible; that's why migrations are expand/contract and forward-only.
- Fix forward + blameless postmortem — reproduce in stage, add the missing test/alert so the same class of failure is caught next time.
kubectl rollout status deploy/myapp -n prod # is it actually healthy?
kubectl rollout history deploy/myapp -n prod
kubectl rollout undo deploy/myapp -n prod # back to previous ReplicaSet
kubectl rollout undo deploy/myapp --to-revision=7 -n prod
# Helm equivalent
helm rollback myapp 12 -n prod
What is GitOps? How do ArgoCD/Flux differ from a push-based pipeline?
GitOps makes Git the single source of truth for declared infrastructure and application state. An in-cluster agent continuously reconciles the live cluster toward what the repo says.
Four principles: declarative config, versioned in Git, changes applied automatically, and continuously reconciled (drift is corrected, not just applied once).
Push vs pull:
- Push (classic CI/CD) — Jenkins/GH Actions holds cluster credentials and runs
kubectl apply from outside. Your CI system needs prod admin access, and manual kubectl edit drift goes unnoticed. - Pull (GitOps — ArgoCD/Flux) — the agent runs inside the cluster and pulls from Git. No external system holds cluster credentials, every change is a reviewed Git commit (full audit trail), rollback is
git revert, and drift is auto-corrected.
Typical split: CI builds and pushes the image, then bumps the tag in a separate config repo; ArgoCD notices the commit and syncs the cluster.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: myapp, namespace: argocd }
spec:
project: default
source:
repoURL: https://github.com/org/k8s-config.git
targetRevision: main
path: apps/myapp/overlays/prod
destination: { server: https://kubernetes.default.svc, namespace: prod }
syncPolicy:
automated: { prune: true, selfHeal: true } # selfHeal = auto-correct drift
syncOptions: [CreateNamespace=true]
Your pipeline takes 45 minutes. How do you make it faster?
Measure first — instrument stage durations and attack the top two. Then, roughly in order of payoff:
- Parallelise — run lint, unit tests, and security scans as concurrent jobs instead of a chain. Split slow test suites across N runners (shards).
- Cache dependencies —
~/.m2, node_modules, Gradle caches, keyed on the lockfile hash so the cache invalidates correctly. - Docker layer caching — order the Dockerfile so dependency install comes before
COPY . .; use BuildKit cache mounts / --cache-from a registry image. - Fail fast — cheapest checks first, so a lint error doesn't cost 45 minutes.
- Run only what changed — path filters / monorepo affected-project detection (Nx, Bazel,
rules: changes:). - Move slow suites off the critical path — heavy E2E/performance on a nightly or post-merge pipeline, with a fast smoke subset on PRs.
- Right-size runners — bigger machines, or self-hosted with warm caches and pre-pulled images.
- Fix flaky tests — retries are hidden minutes.
test:
strategy:
matrix:
shard: [1, 2, 3, 4] # split the suite 4 ways
steps:
- uses: actions/cache@v4
with:
path: ~/.m2/repository
key: mvn-${{ hashFiles('**/pom.xml') }} # key on the lockfile
restore-keys: mvn-
- run: npm test -- --shard=${{ matrix.shard }}/4
What automated gates do you put in a pipeline before code reaches production?
Layered gates, each cheap enough to run on every commit:
- Lint + format — style and obvious bugs, seconds.
- Unit tests + coverage threshold — gate on new/changed code coverage, not the whole legacy repo.
- SAST / code quality — SonarQube quality gate on new code ("clean as you code"): no new critical bugs or security hotspots.
- SCA — dependency scanning — Snyk, Dependabot,
npm audit, OWASP Dependency-Check. Fail on known-exploitable CVEs. - Secret scanning — gitleaks / push protection.
- Container image scanning — Trivy/Grype on the built image; fail on HIGH/CRITICAL with a fixable version.
- IaC scanning — Checkov/tfsec on Terraform (public S3 bucket, open security group).
- Integration + E2E smoke in a deployed environment.
- Manual approval for production (change management).
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: HIGH,CRITICAL
ignore-unfixed: true # don't block on CVEs with no patch yet
exit-code: '1'
- name: SonarQube quality gate
uses: sonarsource/sonarqube-quality-gate-action@master
timeout-minutes: 5
How are pipelines triggered? Webhook vs polling vs schedule.
- Webhook (push) — the SCM calls the CI server the instant a push/PR/tag lands. Immediate, no wasted cycles. This is the default choice.
- SCM polling — CI asks the repo "anything new?" every N minutes. Wasteful and delayed; only used when the CI server can't be reached from the internet (though a firewall-friendly agent or
pollSCM with long intervals is the usual compromise). - Scheduled / cron — nightly regression, weekly dependency updates, periodic security scans, cache warming.
- Manual / parameterised —
workflow_dispatch, "Build with Parameters" — used for deploys, rollbacks, and one-off ops jobs. - Upstream / chained — one pipeline triggers another (build → deploy pipeline, or a shared-library change rebuilding consumers).
- Tag / release — pushing
v1.4.2 triggers the release pipeline. Clean separation of CI from release.
on:
push:
branches: [main]
paths: ['services/payments/**'] # monorepo path filter
pull_request:
schedule:
- cron: '0 2 * * *' # nightly 02:00 UTC
workflow_dispatch: # manual, with inputs
inputs:
environment: { type: choice, options: [dev, stage, prod] }
Which branching model suits CI/CD — trunk-based or GitFlow?
Trunk-based development is what actually enables continuous integration: everyone commits to main (directly or via short-lived branches merged within a day or two), behind feature flags if incomplete. Every merge triggers the full pipeline and is potentially releasable.
GitFlow (long-lived develop, release/*, hotfix/* branches) was designed for versioned software with scheduled releases. In a continuous-delivery world it causes long-lived divergence, painful merges, and delayed integration feedback — the exact problems CI exists to prevent.
Practical stance for a 3–4 YOE DevOps engineer:
- Default to trunk-based + short-lived branches + PR checks + feature flags.
- Use release branches only if you genuinely support multiple versions in the field (on-prem, mobile).
- Protect
main: required status checks, required review, no force-push, linear history.
# Branch protection intent (GitHub)
main:
required_status_checks: [build, unit-tests, sonar, image-scan]
required_approving_review_count: 1
enforce_admins: true
allow_force_pushes: false
required_linear_history: true
Self-hosted runners/agents vs cloud-hosted — how do you choose and scale them?
Cloud-hosted (GitHub-hosted, GitLab SaaS runners): zero maintenance, clean VM per job, pay per minute. Choose when your builds are ordinary and internet-reachable.
Self-hosted: choose when you need
- Private network access — deploying into a VPC, hitting an internal artifact repo or database.
- Cost at scale — heavy build minutes are far cheaper on your own spot instances.
- Special hardware — big memory, GPUs, macOS, ARM.
- Warm caches — pre-pulled base images and populated dependency caches cut minutes off every build.
Scaling pattern: ephemeral autoscaled runners — Kubernetes (Actions Runner Controller, GitLab Kubernetes executor) or EC2 spot with an autoscaler. Each job gets a fresh pod/VM that is destroyed after, so there's no state leakage, and the pool scales to zero when idle.
Security caution: never run untrusted fork PRs on a persistent self-hosted runner — a malicious PR can steal cached credentials and persist on the box.
# GitLab Kubernetes executor — one throwaway pod per job
[[runners]]
executor = "kubernetes"
[runners.kubernetes]
namespace = "gitlab-runner"
image = "alpine:3.20"
cpu_request = "500m"
memory_request = "1Gi"
poll_timeout = 600
How do you build container images inside a CI pipeline safely?
The problem: building a Docker image needs a Docker daemon, but the build itself is running inside a container.
- Docker socket mount (
-v /var/run/docker.sock) — simplest, but the build gets root on the host. Acceptable only on trusted, isolated runners. - Docker-in-Docker (dind) — a privileged sidecar daemon. Works, but
privileged: true is a big security hole and layer caching is lost between jobs unless configured. - Daemonless builders — Kaniko, Buildah, or BuildKit in rootless mode build images without a privileged daemon. This is the recommended approach on Kubernetes runners.
Regardless of builder: use multi-stage builds, tag with the commit SHA, scan the image before publishing, sign it (cosign) if you have supply-chain requirements, and push to a private registry using short-lived credentials (OIDC/IRSA).
# Kaniko — build + push with no Docker daemon, no privileged container
/kaniko/executor \
--context ./ \
--dockerfile ./Dockerfile \
--destination registry/myapp:$CI_COMMIT_SHORT_SHA \
--cache=true --cache-repo=registry/myapp/cache
# then scan before it's ever deployed
trivy image --severity HIGH,CRITICAL --exit-code 1 registry/myapp:$CI_COMMIT_SHORT_SHA
How do you design CI/CD for a monorepo with 20 services?
The core requirement: a change to one service must not build and deploy the other 19.
- Change detection — path filters (
paths: / rules: changes:) for simple cases; a build tool that computes the affected graph (Nx, Turborepo, Bazel, Gradle) when services share libraries — because a change to a shared library must rebuild its dependents. - Per-service pipelines — dynamic/child pipelines generated from the affected list, so each service gets its own build → test → image → deploy path and its own version.
- Shared, versioned templates — reusable workflows / GitLab
include: / Jenkins shared library so 20 services don't own 20 divergent pipeline definitions. - Independent versioning & deploy — one service failing must not block the others; tag artifacts per service.
- Remote build cache — the single biggest speed win in a monorepo; unchanged targets are restored, not rebuilt.
- Merge queue — at high commit volume, serialise merges so main is always green.
# GitLab: generate child pipelines only for affected services
generate:
stage: prepare
script: ./scripts/affected-services.sh > child-pipelines.yml
artifacts: { paths: [child-pipelines.yml] }
trigger-children:
stage: build
trigger:
include: [{ artifact: child-pipelines.yml, job: generate }]
strategy: depend
A pipeline that passed yesterday fails today with no code change. How do you debug it?
Classic "works yesterday, broken today" — something outside your commit moved. Work through what can change without a commit:
- Read the actual error, not the summary — expand the failing step, get the first error, not the last.
- Unpinned dependency — a transitive package, base image tag (
node:20 moved), or a third-party action tag published a new version. Most common cause by far. - Runner/agent change — the hosted runner image was updated (new OS, new default tool version), or a self-hosted agent has a full disk / stale cache.
- External dependency — registry outage, expired certificate, revoked/rotated credential, rate limit (Docker Hub anonymous pulls).
- Flaky test or race — check whether it fails consistently on re-run.
- Shared config drift — someone changed a shared library, template, or environment variable, or a secret expired.
Then: reproduce locally with the same image (docker run the build container), add set -x/debug logging, or SSH into the runner if your CI supports a debug session. Fix the root cause by pinning — lockfiles, digest-pinned base images, SHA-pinned actions.
# reproduce the CI environment locally
docker run --rm -it -v "$PWD":/src -w /src node:20.11.1-alpine sh
# pin so it can't happen again
FROM node:20.11.1-alpine@sha256:2b3c...9f1a
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4, SHA-pinned