← DevOps← DevOps
DevOpsDevOps18 Th7, 2026Jul 18, 202621 phút đọc17 min read

CI/CDCI/CD

Thuộc bộ tài liệu DevOps Roadmap.

Tổng quan

CI/CD (Continuous Integration / Continuous Delivery / Continuous Deployment) là thực hành tự động hóa toàn bộ con đường từ một thay đổi code đến phần mềm chạy trên production. Thay vì tích hợp code theo từng đợt lớn đầy rủi ro và deploy thủ công vài lần mỗi năm, team merge các thay đổi nhỏ thường xuyên, kiểm chứng từng thay đổi bằng pipeline tự động, và release theo cách lặp lại được, ít “kịch tính”. CI/CD chính là động cơ giúp các thực hành DevOps khác — trunk-based development, infrastructure as code, rollout dựa trên observability — thực sự phát huy giá trị.

Lợi ích kinh doanh nằm ở chỗ đạt được cả tốc độ lẫn độ an toàn cùng lúc. Nghiên cứu DORA liên tục chỉ ra rằng các team hiệu suất cao deploy thường xuyên hơn, với tỷ lệ lỗi khi thay đổi (change failure rate) thấp hơn và khôi phục nhanh hơn — chính vì mọi thay đổi đều đi qua cùng một chuỗi build, test và scan tự động. Một pipeline tốt biến câu hỏi “mình release được chưa?” từ một cuộc họp thành một dấu tick xanh.

Với một DevOps engineer, CI/CD là trách nhiệm cốt lõi hàng ngày: thiết kế pipeline, giữ cho pipeline nhanh và ổn định, bảo mật pipeline (pipeline nắm chìa khóa vào production), và chọn chiến lược deployment để giới hạn phạm vi ảnh hưởng khi có sự cố.

Mô hình tư duy: một “con đường đã trải nhựa”

Hãy hình dung CI/CD như một con đường đã trải nhựa (paved road) cho thay đổi. Mỗi commit đi vào cùng một làn đường, qua cùng các chốt kiểm tra, và đi ra hoặc là một artifact sẵn sàng deploy, hoặc là một thất bại nhanh và cụ thể. Giá trị không nằm ở bất kỳ công cụ đơn lẻ nào — nó nằm ở tính chất: chỉ có một cách duy nhất để ship, và cách đó là tự động, quan sát được, và đảo ngược được. Khi việc ship là một lối đi quen thuộc thay vì một sự kiện đặc biệt, chi phí biên cho mỗi lần release giảm về gần 0 — và chi phí thấp cho mỗi lần release chính là thứ mở khóa cho batch nhỏ, thứ mở khóa cho mọi lời hứa còn lại của DevOps.

Hai lực mà một pipeline phải cân bằng là tốc độ phản hồi (developer biết thay đổi của mình tốt hay xấu nhanh đến đâu) và độ tin cậy (bạn chắc chắn đến mức nào rằng “màu xanh” nghĩa là “an toàn để ship”). Hầu như mọi quyết định thiết kế pipeline đều là sự đánh đổi giữa hai lực này.

Kiến thức nền tảng

CI vs Delivery vs Deployment

Thực hànhÝ nghĩaCần con người duyệt trước prod?
Continuous IntegrationDeveloper merge vào nhánh chung ít nhất mỗi ngày; mỗi lần merge kích hoạt build + test tự độngKhông áp dụng (trước release)
Continuous DeliveryMọi thay đổi qua được pipeline đều tạo ra artifact sẵn sàng deploy; release chỉ là một nút bấmCó — duyệt thủ công
Continuous DeploymentMọi thay đổi pass pipeline được deploy lên production tự độngKhông — hoàn toàn tự động

Ba mức này mang tính tích lũy: không thể làm Continuous Deployment nếu chưa vững Continuous Delivery, và không thể làm Delivery nếu chưa có CI thực sự (merge nhỏ, test nhanh và đáng tin).

Một phân biệt cực kỳ quan trọng nhưng thường bị bỏ sót nằm bên dưới tất cả những điều trên: deploy không giống release. Deploy nghĩa là code mới đang chạy trên hạ tầng production; release nghĩa là người dùng được tiếp cận với nó. Feature flag, canary, và traffic shifting tồn tại chính là để tách hai sự kiện này, để bạn có thể deploy liên tục (hoạt động kỹ thuật, rủi ro thấp) trong khi release theo lịch kinh doanh (quyết định sản phẩm, blast radius kiểm soát được).

Continuous Integration thực sự cần những gì

“Bọn em dùng GitHub Actions” không đồng nghĩa với “bọn em làm CI.” CI thực thụ có các điều kiện tiên quyết:

Cấu trúc một pipeline

Một pipeline điển hình chạy các stage sau, “fail fast” ở từng bước:

  1. Build — biên dịch code, resolve dependency, tạo binary/container image.
  2. Test — unit test trước (nhanh), rồi integration/contract test; thu thập coverage.
  3. Scan — phân tích tĩnh (SAST), quét dependency/CVE (SCA), phát hiện secret, quét container image, kiểm tra license.
  4. Package — đánh version cho artifact (image tag, semver, git SHA) và đẩy lên registry/artifact store.
  5. Deploy — triển khai artifact ra môi trường (staging → production), lý tưởng là luôn qua cùng một cơ chế.
  6. Verify — smoke test, health check, và kiểm chứng dựa trên monitoring sau khi deploy.

Nguyên tắc then chốt: build một lần, deploy nhiều lần. Chính artifact đã test ở staging được promote lên production — không bao giờ build lại theo từng môi trường.

Kim tự tháp test bên trong pipeline

Sắp xếp test theo chi phí và tốc độ là thứ giữ cho vòng phản hồi luôn ngắn:

TầngTốc độSố lượngChạy ở đâu
UnitMili-giâyHàng nghìnMỗi lần push, chạy đầu tiên
Integration / contractVài giâyHàng trămMỗi lần push, sau unit
End-to-end (e2e)Vài phútVài chụcKhi merge / trước deploy
Thủ công / khám pháThời gian ngườiRất ítHiếm, chỉ cho luồng giá trị cao

Một kim tự tháp ngược — nhiều e2e chậm, ít unit test — là nguyên nhân phổ biến nhất của một pipeline 45 phút mà ai cũng ghét. Hãy đẩy các assertion xuống tầng rẻ nhất có thể kiểm chứng được chúng.

Từ vựng pipeline

Các tool khác nhau dùng từ khác nhau cho cùng một hình dạng. Học bản đồ ánh xạ này tránh được nhiều nhầm lẫn:

Khái niệmGitHub ActionsGitLab CIJenkins
Toàn bộ automationWorkflowPipelinePipeline
Giai đoạn / nhómJobStageStage
Đơn vị công việcStepJobStep
Máy thực thiRunnerRunnerAgent / Node
Cơ chế tái sử dụngComposite / reusable workflowinclude / templateShared library

Promote artifact giữa các môi trường

Promotion nghĩa là di chuyển một artifact bất biến, có version qua các môi trường thay vì build lại:

Một image đi từ dev lên prod
  1. 01git push
  2. 02CI build imagemyapp:sha-a1b2c3
  3. push lên registry
    03registry
  4. tự động
    04deploy sha-a1b2c3 lên dev
  5. tự động, chạy e2e test
    05deploy sha-a1b2c3 lên staging
  6. duyệt hoặc tự động
    06deploy sha-a1b2c3 lên prod

Khác biệt cấu hình giữa các môi trường đến từ config được inject (env var, config map, parameter store), không bao giờ đến từ một bản build khác. Các registry thường mô hình hóa việc này bằng các tầng repository (ví dụ repo devstagingrelease) hoặc bằng immutable tag kèm metadata theo dõi môi trường.

Vì sao không bao giờ build lại theo môi trường: nếu bạn docker build lại cho prod, bạn có thể kéo về package base-layer mới hơn, một transitive dependency khác, hay một compiler khác — nên artifact prod không phải là artifact bạn đã test. “Build một lần, promote đúng những byte đó” là cách duy nhất để câu “nó pass ở staging” thực sự có ý nghĩa đối với production.

Khái niệm chính

So sánh công cụ

Công cụHostingĐịnh dạng configĐiểm mạnhCần lưu ý
GitHub ActionsSaaS (+ self-hosted runner)YAML trong .github/workflows/Marketplace khổng lồ, tích hợp chặt với GitHub, OIDC lên cloudRủi ro supply-chain từ action bên thứ ba; nên pin action theo SHA
GitLab CISaaS hoặc self-managed.gitlab-ci.ymlNền tảng DevOps all-in-one, registry tích hợp, environments/review appsQuản lý runner ở quy mô lớn (self-managed)
JenkinsSelf-hostedJenkinsfile (Groovy)Linh hoạt vô hạn, hệ sinh thái plugin khổng lồ, thân thiện on-premPlugin phình to, gánh nặng bảo trì, controller “snowflake”
CircleCISaaS (+ self-hosted runner).circleci/config.ymlNhanh, caching/parallelism tốt, orbs để tái sử dụngChi phí ở quy mô lớn; hệ sinh thái nhỏ hơn GHA
Azure DevOps PipelinesSaaS hoặc Serverazure-pipelines.ymlTích hợp sâu với Azure/doanh nghiệp, boards+repos+artifacts trong mộtUI phức tạp; tồn tại song song pipeline classic và YAML

Cũng nên biết: Argo CD / Flux (CD theo kiểu GitOps cho Kubernetes — pipeline chỉ build và push, controller sẽ pull và sync), Tekton (các khối xây pipeline native trên Kubernetes), và Buildkite / Drone (mô hình self-hosted agent, phổ biến khi bạn muốn control plane SaaS nhưng dùng compute riêng).

Deploy kiểu CI-push vs GitOps-pull

Có hai triết lý cho chữ “D” trong CD:

Ví dụ: GitHub Actions workflow

# .github/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read          # mặc định least privilege

concurrency:              # hủy các run cũ bị vượt trên cùng một ref
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test -- --coverage
      - run: npm run lint

  package-deploy:
    needs: build-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging   # kích hoạt required reviewers / protection rules
    permissions:
      contents: read
      id-token: write      # OIDC federation lên cloud — không cần lưu key
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: ap-southeast-1
      - run: |
          docker build -t $ECR_REPO:${GITHUB_SHA::7} .
          docker push $ECR_REPO:${GITHUB_SHA::7}
      - run: ./scripts/deploy.sh staging ${GITHUB_SHA::7}

Hai điểm bổ sung đáng chú ý: concurrency ngăn một hàng đợi các run cũ chất đống khi ai đó push liên tục, và khóa environment: gắn job deploy với protection rules của GitHub environment (required reviewers, wait timers, giới hạn branch).

Ví dụ: cấu hình GitLab CI

# .gitlab-ci.yml
stages: [build, test, scan, deploy]

default:
  tags: [docker]           # nhắm tới một lớp runner

build:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

unit-test:
  stage: test
  image: node:22
  script:
    - npm ci
    - npm test
  coverage: '/Lines\s*:\s*(\d+\.\d+)%/'    # hiển thị coverage trên UI

sast:
  stage: scan
  # hoặc dùng template SAST có sẵn của GitLab:
  # include: - template: Jobs/SAST.gitlab-ci.yml
  script: [ "echo chạy scanner tại đây" ]

deploy-staging:
  stage: deploy
  environment: staging
  script: ./scripts/deploy.sh staging $CI_COMMIT_SHORT_SHA
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

deploy-prod:
  stage: deploy
  environment: production
  when: manual              # cổng duyệt Continuous Delivery
  script: ./scripts/deploy.sh prod $CI_COMMIT_SHORT_SHA
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Lưu ý sự chuyển từ từ khóa cũ only: sang rules: — cách hiện đại, biểu đạt mạnh hơn để điều khiển khi nào job chạy trong GitLab.

Ví dụ: Jenkins declarative pipeline

Jenkins vẫn phổ biến trong doanh nghiệp và on-prem, nên nhận diện được hình dạng của nó là điều quan trọng:

// Jenkinsfile
pipeline {
  agent { label 'docker' }
  options { timeout(time: 20, unit: 'MINUTES') }
  stages {
    stage('Build')  { steps { sh 'make build' } }
    stage('Test')   { steps { sh 'make test' }
                      post { always { junit 'reports/**/*.xml' } } }
    stage('Deploy') {
      when { branch 'main' }
      steps {
        withCredentials([string(credentialsId: 'deploy-token', variable: 'TOKEN')]) {
          sh './scripts/deploy.sh staging'
        }
      }
    }
  }
}

Chiến lược deployment

Chiến lượcCách hoạt độngƯu điểmNhược điểmRollback
RecreateTắt hết bản cũ, khởi động bản mớiCực đơn giản; không chồng versionDowntime trong lúc chuyểnDeploy lại bản cũ
RollingThay thế các instance từng nhóm mộtKhông cần thêm tài nguyên, mặc định trong KubernetesHai version chạy song song; rollback giữa chừng chậmRoll ngược tiếp
Blue-greenHai môi trường giống hệt nhau; chuyển toàn bộ traffic một lầnCutover và rollback tức thì; test được green trước khi chuyểnChi phí hạ tầng gấp đôi; schema DB phải phục vụ cả haiChuyển traffic ngược
CanaryĐưa một % nhỏ traffic sang version mới, theo dõi metrics, tăng dầnKiểm chứng bằng traffic thật, giới hạn phạm vi ảnh hưởngCần traffic splitting + metrics tốt; rollout chậm hơnĐưa traffic về 0%
Feature flagsDeploy “ngầm”, bật theo user/segment lúc runtimeTách deploy khỏi release; kill switch tức thì; A/B testingNợ flag tích tụ; ma trận test phình to; cần dịch vụ quản lý flagTắt flag

Trong thực tế các chiến lược này kết hợp với nhau: continuous deployment code “ngầm” sau feature flag, rollout bằng canary, và tự động rollback khi error budget bị đốt quá nhanh.

Chọn giữa chúng như thế nào:

Cái bẫy schema database

Mỗi chiến lược trên đều chạy hai version code cùng lúc (kể cả blue-green, dù chỉ trong khoảnh khắc). Điều đó nghĩa là schema database phải tương thích với cả code cũ lẫn code mới trong lúc chuyển đổi. Kỹ thuật an toàn là expand/contract (hay parallel change):

  1. Expand — thêm cột/bảng mới trong một migration tương thích ngược. Code cũ bỏ qua nó.
  2. Migrate + deploy — ship code ghi vào cả cũ lẫn mới, đọc mới với fallback.
  3. Contract — khi tất cả instance đã chạy code mới và dữ liệu đã backfill, một release sau sẽ xóa cột cũ.

Đừng bao giờ gộp một thay đổi schema mang tính phá hủy với một lần deploy code trong cùng một bước — điều đó ghép hai thao tác rủi ro độc lập lại và khiến rollback trở nên bất khả thi.

Bảo mật pipeline

Pipeline là mục tiêu giá trị cao — nó nắm credentials vào registry, cloud và production. Một pipeline bị xâm nhập là một production bị xâm nhập.

Ví dụ trust OIDC (AWS)

Trust policy của IAM role ghim chính xác repo và branch nào được phép assume nó — đây chính là thứ làm OIDC an toàn:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
    "StringLike":   { "token.actions.githubusercontent.com:sub": "repo:acme/myapp:ref:refs/heads/main" }
  }
}

Một cấu hình sai phổ biến là điều kiện sub kiểu repo:acme/myapp:*, cho phép bất kỳ branch hay PR nào từ repo đó assume role — kể cả một PR từ fork của kẻ tấn công. Luôn ghim ref chặt nhất có thể theo tình huống sử dụng.

Các cạm bẫy thường gặp

Best Practices

  1. Giữ pipeline nhanh (< 10 phút cho tín hiệu đầu tiên). Pipeline chậm phá hủy vòng phản hồi mà CI sinh ra; chạy test song song, cache dependency triệt để, và chỉ chạy các bộ test đắt khi các bộ rẻ đã pass.
  2. Build một lần, promote cùng một artifact ở mọi nơi. Build lại theo từng môi trường nghĩa là bạn chưa bao giờ thực sự test thứ mình ship; promote đúng những byte bất biến, inject config lúc deploy.
  3. Giữ nhánh main luôn ở trạng thái release được. Main bị hỏng là sự kiện “dừng dây chuyền”; fix hoặc revert ngay lập tức, vì mọi người khác đang bị chặn phía sau.
  4. Chạy các check rẻ nhất trước. Lint và unit test trước integration test, trước e2e — fail nhanh, tiết kiệm tài nguyên, và cho developer lỗi cụ thể nhất có thể.
  5. Đánh version bất biến cho mọi thứ. Tag artifact bằng git SHA; không bao giờ dựa vào tag ghi đè được như latest để quyết định deploy, vì bạn mất khả năng nói chính xác cái gì đang chạy.
  6. Dùng OIDC thay vì lưu credentials cloud. Ngắn hạn, giới hạn scope, audit được — loại bỏ thảm họa rò rỉ secret CI phổ biến nhất và bỏ đi công việc xoay vòng key.
  7. Pin và audit dependency bên thứ ba của pipeline. Action/orb/plugin là code thực thi trong vùng tin cậy của bạn; pin theo full commit SHA và review khi cập nhật như mọi dependency khác.
  8. Tách deploy khỏi release. Dùng flag/canary để ship code và phơi bày nó cho người dùng là hai quyết định độc lập với blast radius độc lập.
  9. Gác cổng production bằng tự động hóa, không phải bằng họp. Mã hóa các quality gate (test, scan, error budget) vào pipeline; chỉ giữ duyệt thủ công ở nơi quy định hoặc rủi ro thực sự đòi hỏi — một cái duyệt cho có là diễn kịch.
  10. Deploy nhỏ và thường xuyên. Diff nhỏ dễ review, dễ test, dễ rollback; tần suất deploy và độ an toàn cùng cải thiện, không mâu thuẫn.
  11. Biến rollback thành con đường hạng nhất, được diễn tập. Một lệnh duy nhất (hoặc tự động khi health check fail) để quay về artifact trước đó — và luyện tập nó, để nó hoạt động cả khi căng thẳng.
  12. Thiết kế thay đổi database theo kiểu expand/contract. Giữ mọi migration tương thích ngược với code đang chạy để rollout và rollback luôn an toàn.
  13. Coi cấu hình pipeline là code. Review nó, test nó, giữ nó trong repo mà nó phục vụ; tránh click cấu hình trên UI mà không ai tái tạo hay audit lại được.
  14. Cô lập code không tin cậy khỏi credentials đặc quyền. Job chạy code dependency và đóng góp từ PR không được phép chạm tới production.
  15. Làm cho thất bại rõ ràng và cụ thể. Một build đỏ nên chỉ vào đúng test bị fail, không đổ ra một bức tường log; đầu tư vào output test tốt và annotation.
  16. Đo lường pipeline bằng DORA metrics. Deployment frequency, lead time for changes, change failure rate và MTTR cho biết đầu tư CI/CD của bạn có thực sự hiệu quả không — và nút thắt nằm ở đâu.

Tài liệu tham khảo

Part of the DevOps Roadmap knowledge base.

Overview

CI/CD (Continuous Integration / Continuous Delivery / Continuous Deployment) is the practice of automating the path from a code change to running software. Instead of integrating work in large, risky batches and deploying manually a few times a year, teams merge small changes frequently, verify each one with an automated pipeline, and ship to production in a repeatable, low-drama way. CI/CD is the engine that makes the other DevOps practices — trunk-based development, infrastructure as code, observability-driven rollouts — actually pay off.

The business case is speed and safety at the same time. The DORA research program consistently shows that elite performers deploy more often, with lower change-failure rates and faster recovery — precisely because every change flows through the same automated gauntlet of builds, tests, and scans. A good pipeline turns “can we release?” from a meeting into a green checkmark.

For a DevOps engineer, CI/CD is a core daily responsibility: designing pipelines, keeping them fast and reliable, securing them (pipelines hold the keys to production), and choosing deployment strategies that limit blast radius when things go wrong.

The mental model: a paved road

Think of CI/CD as a paved road for change. Every commit enters the same lane, passes the same checkpoints, and exits as either a deployable artifact or a fast, specific failure. The value is not any single tool — it is the property that there is only one way to ship, and it is automated, observable, and reversible. When shipping is a well-worn path rather than a bespoke event, the marginal cost of a release drops toward zero, and low cost per release is what unlocks small batches, which is what unlocks everything else DevOps promises.

The two forces a pipeline balances are feedback speed (how fast a developer learns their change is good or bad) and confidence (how sure you are that “green” means “safe to ship”). Almost every pipeline design decision is a trade between these two.

Fundamentals

CI vs Delivery vs Deployment

PracticeWhat it meansHuman gate before prod?
Continuous IntegrationDevelopers merge to a shared branch at least daily; every merge triggers automated build + testsN/A (pre-release)
Continuous DeliveryEvery change that passes the pipeline produces a deployable artifact; releasing is a button pressYes — manual approval
Continuous DeploymentEvery passing change is deployed to production automaticallyNo — fully automated

The progression is cumulative: you cannot do Continuous Deployment without solid Continuous Delivery, and you cannot do Delivery without real CI (small merges, fast trustworthy tests).

A crucial and often-missed distinction sits underneath all of this: deploy is not the same as release. Deploy means the new code is running on production infrastructure; release means users are exposed to it. Feature flags, canaries, and traffic shifting exist precisely to separate these two events, so you can deploy continuously (technical activity, low risk) while releasing on a business schedule (product decision, controllable blast radius).

What real Continuous Integration requires

“We use GitHub Actions” is not the same as “we do CI.” Genuine CI has preconditions:

Anatomy of a pipeline

A typical pipeline runs these stages, failing fast at each step:

  1. Build — compile code, resolve dependencies, produce a binary/container image.
  2. Test — unit tests first (fast), then integration/contract tests; collect coverage.
  3. Scan — static analysis (SAST), dependency/CVE scanning (SCA), secret detection, container image scanning, license checks.
  4. Package — version the artifact (image tag, semver, git SHA) and push it to a registry/artifact store.
  5. Deploy — roll the artifact out to an environment (staging → production), ideally via the same mechanism every time.
  6. Verify — smoke tests, health checks, and monitoring-based validation after deploy.

Key principle: build once, deploy many. The exact artifact tested in staging is the one promoted to production — never rebuilt per environment.

The test pyramid inside the pipeline

Ordering tests by cost and speed is what keeps the feedback loop tight:

LayerSpeedQuantityRuns where
UnitMillisecondsThousandsEvery push, first
Integration / contractSecondsHundredsEvery push, after unit
End-to-end (e2e)MinutesDozensOn merge / pre-deploy
Manual / exploratoryHuman timeFewRare, high-value paths only

An inverted pyramid — many slow e2e tests, few unit tests — is the most common cause of a 45-minute pipeline that everyone hates. Push assertions down to the cheapest layer that can make them.

Pipeline vocabulary

Different tools use different words for the same shapes. Learning the mapping saves confusion:

ConceptGitHub ActionsGitLab CIJenkins
Whole automationWorkflowPipelinePipeline
Phase / groupingJobStageStage
Unit of workStepJobStep
Execution machineRunnerRunnerAgent / Node
Reuse mechanismComposite / reusable workflowinclude / templateShared library

Artifact promotion between environments

Promotion means moving an immutable, versioned artifact through environments rather than rebuilding:

One image promoted from dev to prod
  1. 01git push
  2. 02CI builds imagemyapp:sha-a1b2c3
  3. push to registry
    03registry
  4. automatic
    04deploy sha-a1b2c3 to dev
  5. automatic, run e2e tests
    05deploy sha-a1b2c3 to staging
  6. approval or automatic
    06deploy sha-a1b2c3 to prod

Configuration differences between environments come from injected config (env vars, config maps, parameter stores), never from a different build. Registries often model this with repository tiers (e.g., devstagingrelease repos) or with immutable tags plus environment-tracking metadata.

Why never rebuild per environment: if you docker build again for prod, you may pull newer base-layer packages, a different transitive dependency, or a different compiler — so the prod artifact is not the artifact you tested. “Build once, promote the same bytes” is the only way “it passed in staging” is a meaningful statement about production.

Key Concepts

Tool comparison

ToolHostingConfig formatStrengthsWatch out for
GitHub ActionsSaaS (+ self-hosted runners)YAML in .github/workflows/Huge marketplace, tight GitHub integration, OIDC to cloudsThird-party action supply-chain risk; pin actions by SHA
GitLab CISaaS or self-managed.gitlab-ci.ymlSingle-application DevOps platform, built-in registry, environments/review appsRunner management at scale (self-managed)
JenkinsSelf-hostedJenkinsfile (Groovy)Infinitely flexible, massive plugin ecosystem, on-prem friendlyPlugin sprawl, maintenance burden, snowflake controllers
CircleCISaaS (+ self-hosted runners).circleci/config.ymlFast, good caching/parallelism, orbs for reuseCost at scale; smaller ecosystem than GHA
Azure DevOps PipelinesSaaS or Serverazure-pipelines.ymlDeep Azure/enterprise integration, boards+repos+artifacts in oneUI complexity; classic vs YAML pipeline duality

Also worth knowing: Argo CD / Flux (GitOps-style CD for Kubernetes — the pipeline builds and pushes, a controller pulls and syncs), Tekton (Kubernetes-native pipeline building blocks), and Buildkite / Drone (self-hosted-agent models popular where you want SaaS control plane but your own compute).

CI-push vs GitOps-pull deployment

There are two philosophies for the “D” in CD:

Example: GitHub Actions workflow

# .github/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read          # least privilege by default

concurrency:              # cancel superseded runs on the same ref
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test -- --coverage
      - run: npm run lint

  package-deploy:
    needs: build-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging   # enables required reviewers / protection rules
    permissions:
      contents: read
      id-token: write      # OIDC federation to cloud — no stored keys
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: ap-southeast-1
      - run: |
          docker build -t $ECR_REPO:${GITHUB_SHA::7} .
          docker push $ECR_REPO:${GITHUB_SHA::7}
      - run: ./scripts/deploy.sh staging ${GITHUB_SHA::7}

Two additions worth calling out: concurrency prevents a queue of stale runs from piling up when someone pushes rapidly, and the environment: key ties the deploy job to GitHub’s environment protection rules (required reviewers, wait timers, branch restrictions).

Example: GitLab CI config

# .gitlab-ci.yml
stages: [build, test, scan, deploy]

default:
  tags: [docker]           # target a runner class

build:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

unit-test:
  stage: test
  image: node:22
  script:
    - npm ci
    - npm test
  coverage: '/Lines\s*:\s*(\d+\.\d+)%/'    # surface coverage in the UI

sast:
  stage: scan
  # use GitLab's built-in SAST template:
  # include: - template: Jobs/SAST.gitlab-ci.yml
  script: [ "echo run scanner here" ]

deploy-staging:
  stage: deploy
  environment: staging
  script: ./scripts/deploy.sh staging $CI_COMMIT_SHORT_SHA
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

deploy-prod:
  stage: deploy
  environment: production
  when: manual              # Continuous Delivery gate
  script: ./scripts/deploy.sh prod $CI_COMMIT_SHORT_SHA
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Note the shift from the older only: keyword to rules:, which is the modern, more expressive way to control when jobs run in GitLab.

Example: Jenkins declarative pipeline

Jenkins remains ubiquitous in enterprises and on-prem, so recognizing its shape matters:

// Jenkinsfile
pipeline {
  agent { label 'docker' }
  options { timeout(time: 20, unit: 'MINUTES') }
  stages {
    stage('Build')  { steps { sh 'make build' } }
    stage('Test')   { steps { sh 'make test' }
                      post { always { junit 'reports/**/*.xml' } } }
    stage('Deploy') {
      when { branch 'main' }
      steps {
        withCredentials([string(credentialsId: 'deploy-token', variable: 'TOKEN')]) {
          sh './scripts/deploy.sh staging'
        }
      }
    }
  }
}

Deployment strategies

StrategyHow it worksProsConsRollback
RecreateStop all old, start all newDead simple; no version overlapDowntime during the swapRedeploy old
RollingReplace instances a few at a timeNo extra capacity needed, default in KubernetesTwo versions coexist; slow rollback mid-rollRoll back forward
Blue-greenTwo identical environments; switch traffic all at onceInstant cutover and rollback; test green before switch2x infrastructure cost; DB schema must serve bothFlip traffic back
CanarySend a small % of traffic to the new version, watch metrics, ramp upReal-traffic validation, limited blast radiusNeeds traffic splitting + good metrics; slower rolloutShift traffic to 0%
Feature flagsDeploy dark, enable per user/segment at runtimeDecouples deploy from release; instant kill switch; A/B testingFlag debt; testing matrix grows; needs a flag serviceToggle flag off

In practice these combine: continuous deployment of dark code behind flags, rolled out via canary, with automated rollback on error-budget burn.

Choosing between them:

The database schema trap

Every one of these strategies runs two versions of your code at once (even blue-green, momentarily). That means your database schema must be compatible with both the old and new code during the transition. The safe technique is expand/contract (a.k.a. parallel change):

  1. Expand — add the new column/table in a backward-compatible migration. Old code ignores it.
  2. Migrate + deploy — ship code that writes to both old and new, reads new with fallback.
  3. Contract — once all instances run the new code and data is backfilled, a later release drops the old column.

Never combine a destructive schema change with a code deploy in the same step — that couples two independently risky operations and makes rollback impossible.

Pipeline security

The pipeline is a high-value target — it has credentials to your registry, cloud, and production. A compromised pipeline is a compromised production.

OIDC trust example (AWS)

The IAM role’s trust policy pins exactly which repo and branch may assume it — this is what makes OIDC safe:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
    "StringLike":   { "token.actions.githubusercontent.com:sub": "repo:acme/myapp:ref:refs/heads/main" }
  }
}

A common misconfiguration is a sub condition of repo:acme/myapp:*, which lets any branch or PR from that repo assume the role — including an attacker’s fork-based PR. Always pin the ref as tightly as the use case allows.

Common pitfalls

Best Practices

  1. Keep the pipeline fast (< 10 minutes to first signal). Slow pipelines destroy the feedback loop that CI exists to provide; parallelize tests, cache dependencies aggressively, and run the expensive suites only when the cheap ones pass.
  2. Build once, promote the same artifact everywhere. Rebuilding per environment means you never actually tested what you shipped; promote immutable bytes, inject config at deploy time.
  3. Make the main branch always releasable. Broken main is a stop-the-line event; fix or revert immediately, because everyone else is blocked behind it.
  4. Run the cheapest checks first. Lint and unit tests before integration tests before e2e — fail fast, save compute, and give the developer the most specific error possible.
  5. Version everything immutably. Tag artifacts with the git SHA; never overwrite tags like latest for deployment decisions, because you lose the ability to say exactly what is running.
  6. Use OIDC instead of stored cloud credentials. Short-lived, scoped, auditable — it eliminates the single most common CI secret-leak disaster and removes rotation toil.
  7. Pin and audit third-party pipeline dependencies. Actions/orbs/plugins are executable code in your trust boundary; pin by full commit SHA and review updates like any other dependency.
  8. Separate deploy from release. Use flags/canaries so shipping code and exposing it to users are independent decisions with independent blast radius.
  9. Gate production with automation, not meetings. Encode quality gates (tests, scans, error budgets) in the pipeline; keep manual approval only where regulation or genuine risk requires it — a rubber-stamp approval is theater.
  10. Deploy small and often. Small diffs are easier to review, test, and roll back; deployment frequency and safety improve together, not in tension.
  11. Make rollback a first-class, rehearsed path. One command (or automatic on failed health checks) to return to the previous artifact — and practice it, so it works under stress.
  12. Design database changes as expand/contract. Keep every migration backward-compatible with the currently running code so rollout and rollback are always safe.
  13. Treat pipeline config as code. Review it, test it, keep it in the repo it serves; avoid clicking configuration into a UI that nobody can reproduce or audit.
  14. Isolate untrusted code from privileged credentials. The job running dependency code and PR contributions must not be able to reach production.
  15. Make failures loud and specific. A red build should point at the one failing test, not dump a wall of logs; invest in good test output and annotations.
  16. Measure your pipeline with DORA metrics. Deployment frequency, lead time for changes, change-failure rate, and MTTR tell you whether your CI/CD investment is actually working — and where the bottleneck is.

References