CI/CD & Triển khaiCI/CD & Deployment
Thuộc bộ kiến thức Backend Roadmap.
Tổng quan
CI/CD là con đường tự động đưa một thay đổi code từ lúc developer commit cho đến khi chạy trên production, một cách an toàn và lặp lại được. Nó loại bỏ các bước thủ công chậm chạp và dễ sai — “chạy trên máy tôi mà”, copy artifact bằng tay, những đêm SSH sửa lỗi — và thay bằng một pipeline: một chuỗi stage được định nghĩa rõ, chạy trên mỗi thay đổi và hoặc cho thay đổi đi tiếp, hoặc chặn nó lại.
Với một backend engineer, điều này rất quan trọng vì một backend service tồn tại lâu dài, có state (databases!), và thường được nhiều client dùng chung. Một lần deploy hỏng có thể làm rớt request, hỏng dữ liệu, hoặc làm sập tất cả consumer cùng lúc. CI/CD cho bạn công cụ để ship nhiều thay đổi nhỏ một cách tự tin: feedback nhanh về tính đúng đắn, build tái lập được, rollout có kiểm soát, và một đường quay lại nhanh khi có sự cố.
Ghi chú này tập trung vào backend và bổ sung cho bài DevOps chuyên sâu tại ../../devops/vi/11-ci-cd.md — bài đó bao quát tooling pipeline theo hướng tổng quát hơn. Ở đây ta tập trung vào những gì đội backend làm cụ thể: đóng gói service thành container, xử lý database migrations, chọn deployment strategy, và đạt được zero-downtime deploy cùng rollback.
Kiến thức nền tảng
CI, Continuous Delivery và Continuous Deployment
Ba thuật ngữ này liên quan nhưng khác nhau — điểm khác biệt nằm ở automation đi xa tới đâu.
| Thuật ngữ | Tự động hóa cái gì | Có cổng người duyệt? | Kết quả khi pipeline xanh |
|---|---|---|---|
| Continuous Integration (CI) | Merge → build → test → static analysis trên mỗi push/PR | Không áp dụng (developer merge thường xuyên) | Một artifact đã được kiểm chứng, đã đóng gói |
| Continuous Delivery (CD) | Mọi thứ của CI, thêm deploy lên staging và sẵn sàng release lên prod | Có — người bấm nút “deploy to prod” | Artifact luôn deploy được; release prod chỉ còn một nút bấm |
| Continuous Deployment (CD) | Mọi thứ ở trên, và cả release prod | Không — mỗi build xanh tự động lên prod | Thay đổi đã live trên production, không cần bước con người |
Ý tưởng cốt lõi của CI là tích hợp sớm và thường xuyên: mọi người merge vào một nhánh main chung nhiều lần mỗi ngày, và mỗi lần merge được kiểm chứng tự động nên các vấn đề tích hợp lộ ra trong vài phút thay vì dồn vào một “tuần merge” đau khổ về sau. Xem Version Control & Collaboration cho workflow trunk-based giúp điều này khả thi.
Cả hai loại CD đều xây trên nền CI. Câu hỏi duy nhất là bước release production cuối cùng do con người kích hoạt (Delivery) hay tự động (Deployment). Continuous Deployment đòi hỏi độ tin cậy cao vào automated test và observability.
Pipeline: build → test → package → deploy
Một backend pipeline là một chuỗi các stage. Thay đổi chỉ đi sang stage kế tiếp nếu stage hiện tại pass.
commit ─▶ build ─▶ test ─▶ package ─▶ deploy (staging) ─▶ [gate] ─▶ deploy (prod)
- Build — compile code, resolve dependencies. Với ngôn ngữ thông dịch thì đây là cài dependency + kiểm tra syntax/type.
- Test — chạy unit test, integration test, linter, security scanner. Đây là các quality gate. Xem Testing.
- Package — tạo ra một artifact bất biến (immutable), có version: thường là một container image (hoặc jar, wheel, binary). Chính artifact này được deploy ở mọi nơi.
- Deploy — đưa artifact ra một environment, chạy migrations, và xác minh bằng health check.
Quy tắc vàng: build một lần, deploy nhiều nơi. Artifact được test ở staging phải giống hệt (byte-for-byte) artifact tới production. Rebuild riêng cho từng environment sẽ tái tạo lại nhóm bug kiểu “chạy được ở staging”.
Khái niệm chính
CI trong thực tế
Triggers. Pipeline chạy theo sự kiện: push lên một nhánh, mở hoặc cập nhật một pull request, một tag, một lịch (cron), hoặc kích hoạt thủ công. Cấu hình phổ biến: chạy toàn bộ test suite trên mỗi PR, và chạy build + package + deploy khi merge vào main.
Chạy test và linter. CI job checkout code, cài dependency, rồi chạy lệnh test và lint. Test cần database hay cache sẽ dựng service container (ví dụ một Postgres dùng-một-lần) để integration test chạy trên một engine thật.
Build artifact. Sau khi build xanh, pipeline upload artifact — một container image lên registry, hoặc một binary đã compile — gắn tag theo commit SHA để truy vết được.
Caching. Việc tải dependency và output build được cache giữa các lần chạy (key theo hash của lockfile) để cắt bớt vài phút mỗi build. Docker layer caching làm điều tương tự cho việc build image.
Matrix builds. Chạy cùng một job trên nhiều tổ hợp biến — nhiều version ngôn ngữ, OS, hoặc version database — song song, để bắt lỗi tương thích.
Ví dụ GitHub Actions — một backend service
Pipeline cho một backend Go (hoặc bất kỳ ngôn ngữ nào): lint và test với một Postgres thật, rồi build và push Docker image khi ở nhánh main.
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
cache: true # cache module downloads + build cache
- name: Lint
run: go vet ./...
- name: Test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test?sslmode=disable
run: go test -race -coverprofile=cover.out ./...
build-and-push:
needs: test # only runs if tests pass
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to GitHub Container Registry
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha # reuse Docker layer cache across runs
cache-to: type=gha,mode=max
Chú ý cách job test dùng một services: Postgres kèm health check, và build-and-push khai báo needs: test để việc đóng gói không bao giờ xảy ra trên một build đỏ. Image được gắn tag github.sha — bất biến và truy vết được.
Đóng gói backend thành container
Container là định dạng đóng gói chủ đạo cho backend service: chúng gói app cùng đúng runtime và dependencies, nên nó chạy y hệt trên laptop, trong CI, và trên production. Xem ../kubernetes để orchestrate chúng ở quy mô lớn.
Best practice cho image:
- Multi-stage build — compile trong stage “builder” nặng, chỉ copy binary/artifact cuối cùng sang một runtime stage gọn. Image ship ra vẫn tí hon.
- Base image nhỏ — biến thể
distroless,alpine, hoặc-slim. Ít thứ để tải, attack surface nhỏ hơn. - Chạy dưới quyền non-root — tạo một user không đặc quyền; đừng bao giờ chạy process bằng
root. - Pin version — pin base image (lý tưởng là theo digest) và version dependency để tái lập được.
- Tận dụng layer caching — copy file manifest dependency và cài đặt trước khi copy source, để thay đổi code không làm mất hiệu lực layer dependency.
.dockerignore— giữ.git, test, và secret ra khỏi build context.
# ---- build stage ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # cached unless go.mod/go.sum change
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
# ---- runtime stage ----
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app /app
USER nonroot:nonroot # non-root
EXPOSE 8080
ENTRYPOINT ["/app"]
Image cuối cùng chỉ chứa binary static và một base tối giản — không compiler, không shell, không package manager.
Deployment environment & cấu hình
Backend đi qua một chuỗi environment, mỗi cái gần với thực tế hơn:
| Environment | Mục đích | Dữ liệu | Ai dùng |
|---|---|---|---|
| dev / local | Vòng lặp nhanh khi đang code | Giả/seed | Developer |
| staging / pre-prod | Kiểm chứng cuối, giống prod | Giống prod (đã ẩn danh) | QA, automated test |
| production | Traffic thật | Thật | Người dùng cuối |
Cùng một artifact bất biến di chuyển qua tất cả; chỉ cấu hình thay đổi giữa các environment. Đây là nguyên tắc Twelve-Factor App: lưu config trong environment, không trong code. Database URL, feature flag, và log level đến từ environment variable — cùng một binary kết nối tới DB dev hay DB prod chỉ tùy theo DATABASE_URL.
Secret trong CI/CD. API key, mật khẩu DB, và signing key tuyệt đối không được nằm trong repo. Lưu chúng trong secret store được mã hóa của CI provider (GitHub Actions Secrets, Vault, cloud secret manager) và inject vào như environment variable lúc deploy. Trong pipeline, mask chúng trong log và giới hạn phạm vi thật hẹp (theo từng environment, least privilege). Xem security và Auth.
Deployment strategy
Cách bạn thay version cũ bằng version mới quyết định phạm vi ảnh hưởng (blast radius) và tốc độ rollback.
| Strategy | Cách hoạt động | Downtime | Rollback | Chi phí | Hợp nhất cho |
|---|---|---|---|---|---|
| Recreate | Dừng cũ, khởi động mới | Có (ngắn) | Deploy lại bản cũ | Thấp | Dev, hoặc app chịu được downtime |
| Rolling | Thay từng ít instance một | Không | Rollback từ từ | Thấp | Mặc định cho stateless service / Kubernetes |
| Blue-green | Chạy hai environment đầy đủ; chuyển traffic cùng lúc | Không | Tức thì (chuyển ngược lại) | Cao (2x hạ tầng) | Cutover nhanh, atomic, dễ rollback |
| Canary | Đẩy một % nhỏ traffic sang version mới, rồi tăng dần | Không | Dừng tăng, route ngược lại | Trung bình | Thay đổi rủi ro cao; cần metric tốt |
| Feature flags | Ship code ở trạng thái tắt, bật tính năng theo user/% lúc runtime | Không | Tắt flag đi | Thấp | Tách deploy khỏi release; A/B test |
- Rolling là mặc định trong Kubernetes: nó dần thay pod trong khi vẫn giữ service khả dụng, được gác bởi readiness probe.
- Blue-green cho một cú chuyển tức thì, atomic, và rollback tức thì, đổi lại phải chạy hai stack đầy đủ. Cẩn thận với database dùng chung — cả hai màu phải tương thích schema.
- Canary an toàn nhất cho thay đổi rủi ro: mở 1% → 5% → 25% → 100% trong khi theo dõi error rate và latency; tự động dừng và rollback nếu metric xấu đi. Đây là progressive delivery.
- Feature flags tách việc deploy code khỏi việc release một tính năng. Bạn merge và deploy phần việc chưa xong đằng sau một flag đang tắt, rồi bật lên độc lập — và tắt tức thì nếu nó hỏng — mà không cần redeploy.
Hosting / runtime target cho backend
Artifact thực sự chạy ở đâu? Phổ này đánh đổi giữa quyền kiểm soát và gánh nặng vận hành.
| Target | Bạn quản lý | Scaling | Hợp khi |
|---|---|---|---|
| VMs / bare metal | OS, runtime, app, scaling | Thủ công / autoscaling group | Cần toàn quyền, legacy, phần cứng đặc thù, tải ổn định |
| Container + orchestrator (Kubernetes) | App + manifest; platform lo scheduling | Ngang, tự động | Nhiều service, cần portability & self-healing ở quy mô lớn |
| PaaS (Heroku, Render, Fly.io, App Engine) | Chỉ app của bạn | Tự động | Đội nhỏ muốn nhanh hơn là kiểm soát |
| Serverless / FaaS (Lambda, Cloud Functions) | Chỉ code của function | Tự động, xuống tới zero | Event-driven, tải giật cục, hoặc traffic thấp/không đều |
Đi xuống bảng để bớt vất vả vận hành, đi lên để có nhiều kiểm soát hơn. Xem ../kubernetes cho hướng container-orchestration.
Serverless cho backend
FaaS (Functions as a Service) chạy code của bạn để phản hồi sự kiện (HTTP request, message trong queue, cron) mà bạn không phải quản lý server. Platform scale số instance lên xuống — thậm chí xuống zero — và bạn trả tiền theo mỗi lần invoke.
- Stateless là bắt buộc. Function là ephemeral; đừng giữ state trong bộ nhớ giữa các lần invoke. Lưu state trong database, cache, hoặc object store.
- Cold start. Khi không có instance nào đang “ấm”, platform phải khởi tạo một cái mới, thêm latency (vài chục ms tới vài giây, tệ hơn với runtime JVM/nặng). Giảm nhẹ bằng runtime nhẹ hơn, package nhỏ hơn, và provisioned concurrency.
- Quản lý connection. Connection pool DB truyền thống không hợp — hàng nghìn instance function có thể làm cạn connection DB. Dùng một proxy/pooler (ví dụ RDS Proxy, PgBouncer) hoặc một data API dựa trên HTTP.
- Hợp cho: xử lý event, webhook, job theo lịch, code kết dính (glue), traffic giật cục/khó đoán, và API mà trả-tiền-theo-request lời hơn là để server chạy không.
- Không hợp cho: job chạy lâu (giới hạn thời gian thực thi), đường đi cần latency thấp ổn định (cold start), và workload có khối lượng cao đều đặn (một container luôn bật thường rẻ hơn).
Database migration trong pipeline
Backend có state, và thay đổi schema là phần khó nhằn nhất của deployment. Một migration là một thay đổi schema có version, có thứ tự (tạo table, thêm column, backfill data), được áp dụng bởi công cụ như Flyway, Liquibase, Alembic, hoặc golang-migrate.
Chạy migration như một bước pipeline tường minh trước (hoặc song song với) việc deploy app, chứ không phải từ bên trong app lúc khởi động (việc này bị race giữa các replica). Quy tắc then chốt cho zero-downtime: migration phải backward-compatible — cả version code cũ lẫn mới đều chạy được với schema trung gian.
Dùng pattern expand-and-contract (parallel change) cho các thay đổi phá vỡ (breaking), ví dụ đổi tên một column:
- Expand — thêm column mới; deploy code ghi vào cả column cũ lẫn mới.
- Backfill — copy dữ liệu hiện có sang column mới.
- Migrate reads — deploy code đọc từ column mới.
- Contract — khi không còn gì dùng column cũ, drop nó ở một lần deploy sau.
Mỗi bước deploy được độc lập và an toàn để rollback. Đừng bao giờ gộp một thay đổi schema hủy hoại (destructive) với chính lần deploy code phụ thuộc vào nó.
Zero-downtime deploy và rollback
Zero-downtime nghĩa là người dùng không bao giờ thấy lỗi trong lúc release. Nó đòi hỏi:
- Một deployment strategy giữ được năng lực phục vụ (rolling / blue-green / canary).
- Health check / readiness probe — platform chỉ route traffic tới một instance khi nó báo healthy, và ngừng route trước khi tắt một instance.
- Graceful shutdown — khi nhận
SIGTERM, ngừng nhận request mới, xử lý xong các request đang bay, rồi mới thoát. Kết hợp với connection draining, không request nào bị rớt. - Migration backward-compatible (như trên) để hai version cùng tồn tại.
Rollback là lưới an toàn của bạn. Vì artifact là bất biến và có version, rollback chỉ là deploy lại image tag “tốt đã biết” trước đó. Hãy biến nó thành một thao tác hạng nhất, nhanh, một lệnh (hoặc tự động). Lưu ý sự bất đối xứng: code rollback sạch sẽ; data thì không. Một migration đã drop một column không thể “un-run” mà không mất dữ liệu — đó chính là lý do expand-and-contract giữ các thay đổi destructive tách riêng và làm muộn.
An toàn khi release và progressive delivery
Ship an toàn bằng cách xếp lớp các gate và guardrail:
- Automated test làm gate — pipeline chặn mọi thay đổi fail unit/integration test, lint, hoặc security scan. Đây là tuyến phòng thủ đầu tiên và rẻ nhất (Testing).
- Health check — endpoint liveness và readiness cho platform tự phát hiện và thay instance bệnh.
- Progressive delivery — canary + feature flag mở thay đổi cho một lát cắt có blast-radius nhỏ dần trước, nên một release hỏng chỉ ảnh hưởng 1% traffic, không phải 100%.
- Gắn với observability — rollout tự động nên theo dõi tín hiệu thật: error rate, latency (p95/p99), saturation. Nếu chúng xấu đi trong lúc canary, dừng và rollback tự động. Điều này khép vòng với Observability & Monitoring — bạn không thể làm progressive delivery an toàn nếu thiếu metric, log, và trace tốt để đánh giá từng bước.
Best Practices
- Build một lần, deploy nhiều nơi. Thăng cùng một artifact bất biến, gắn tag SHA qua dev → staging → prod. Đừng bao giờ rebuild theo từng environment.
- Giữ pipeline nhanh. Feedback nhanh (nhắm dưới ~10 phút để xanh) giúp developer merge thường xuyên. Cache dependency và Docker layer, và song song hóa bằng matrix build.
- Biến test thành gate. Không deploy trên build đỏ. Chạy unit + integration + linter + security scan tự động trên mỗi PR.
- Lưu config trong environment, secret trong secret manager. Config theo twelve-factor; đừng bao giờ commit secret; inject lúc deploy với least privilege.
- Đóng gói thành container image nhỏ, non-root, multi-stage, pin theo digest.
- Chọn một zero-downtime strategy (rolling/blue-green/canary) với readiness probe và graceful shutdown; tránh recreate trên production.
- Làm migration backward-compatible và chạy như một bước tường minh; dùng expand-and-contract cho thay đổi breaking; đừng bao giờ ghép một destructive migration với code cần nó.
- Tách deploy khỏi release bằng feature flag — ship ở trạng thái tắt, bật dần, tắt tức thì.
- Làm rollback dễ và nhanh — một lệnh hoặc tự động; hãy test nó. Nhớ rằng data không rollback như code.
- Tự động hóa mọi thứ và giữ trong version control — định nghĩa pipeline (YAML) nằm trong repo và được review như mọi code khác.
- Gác các rollout progressive bằng metric thật — buộc việc thăng/hủy canary vào error rate và latency từ observability stack của bạn.
Tài liệu tham khảo
- roadmap.sh/backend — backend roadmap mà ghi chú này bám theo
- Martin Fowler: Continuous Integration
- Martin Fowler: Continuous Delivery & Blue-Green Deployment
- The Twelve-Factor App
- GitHub Actions Documentation
- Docker: Best practices for building images
- Kubernetes: Deployments & rolling updates
- AWS: Blue/Green Deployments (Whitepaper)
Part of the Backend Roadmap knowledge base.
Overview
CI/CD is the automated path that carries a code change from a developer’s commit to running in production, safely and repeatedly. It removes the slow, error-prone manual steps — “works on my machine”, hand-copied artifacts, midnight SSH sessions — and replaces them with a pipeline: a defined sequence of stages that runs on every change and either promotes the change or stops it.
For a backend engineer this matters because a backend service is long-lived, stateful (databases!), and often shared by many clients. A bad deploy can drop requests, corrupt data, or take down every consumer at once. CI/CD gives you the tools to ship many small changes with confidence: fast feedback on correctness, reproducible builds, controlled rollout, and a fast path back when something goes wrong.
This note is backend-focused and complements the DevOps deep dive in ../../devops/en/11-ci-cd.md, which covers pipeline tooling in more general breadth. Here we concentrate on what backend teams do specifically: packaging services as containers, handling database migrations, choosing deployment strategies, and achieving zero-downtime releases and rollbacks.
Fundamentals
CI, Continuous Delivery, and Continuous Deployment
These three terms are related but distinct — the difference is how far automation goes.
| Term | What it automates | Human gate? | Result of a green pipeline |
|---|---|---|---|
| Continuous Integration (CI) | Merge → build → test → static analysis on every push/PR | N/A (developers merge frequently) | A verified, packaged artifact |
| Continuous Delivery (CD) | Everything in CI, plus deploy to staging and readiness to release to prod | Yes — a person clicks “deploy to prod” | An artifact that is always deployable; prod release is one button |
| Continuous Deployment (CD) | Everything above, and prod release too | No — every green build goes live automatically | The change is live in production, no human step |
The key idea of CI is integrate early and often: everyone merges to a shared main branch many times a day, and each merge is automatically verified so integration problems surface in minutes instead of at a painful “merge week” later. See Version Control & Collaboration for the trunk-based workflow that makes this work.
Both flavors of CD build on CI. The only question is whether the final production release is triggered by a human (Delivery) or automatically (Deployment). Continuous Deployment demands high trust in your automated tests and observability.
The pipeline: build → test → package → deploy
A backend pipeline is a sequence of stages. A change only moves to the next stage if the current one passes.
commit ─▶ build ─▶ test ─▶ package ─▶ deploy (staging) ─▶ [gate] ─▶ deploy (prod)
- Build — compile the code, resolve dependencies. For interpreted languages this is dependency install + a syntax/type check.
- Test — run unit tests, integration tests, linters, security scanners. These are the quality gates. See Testing.
- Package — produce an immutable, versioned artifact: usually a container image (or a jar, wheel, binary). This exact artifact is what gets deployed everywhere.
- Deploy — roll the artifact out to an environment, run migrations, and verify with health checks.
The golden rule: build once, deploy many. The artifact tested in staging must be byte-for-byte the same artifact that reaches production. Rebuilding per environment reintroduces the “works in staging” class of bugs.
Key Concepts
CI in practice
Triggers. Pipelines run on events: push to a branch, opening or updating a pull request, a tag, a schedule (cron), or a manual dispatch. A common setup: run the full test suite on every PR, and run build + package + deploy on merges to main.
Running tests and linters. The CI job checks out the code, installs dependencies, then runs the test and lint commands. Tests that need a database or cache spin up service containers (e.g. a throwaway Postgres) so integration tests run against a real engine.
Build artifacts. After a green build, the pipeline uploads the artifact — a container image to a registry, or a compiled binary — tagged with the commit SHA so it’s traceable.
Caching. Dependency downloads and build outputs are cached between runs (keyed by a lockfile hash) to cut minutes off each build. Docker layer caching does the same for image builds.
Matrix builds. Run the same job across combinations of variables — multiple language versions, OSes, or database versions — in parallel, to catch compatibility issues.
GitHub Actions example — a backend service
A pipeline for a Go (or any) backend: lint and test against a real Postgres, then build and push a Docker image on main.
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
cache: true # cache module downloads + build cache
- name: Lint
run: go vet ./...
- name: Test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test?sslmode=disable
run: go test -race -coverprofile=cover.out ./...
build-and-push:
needs: test # only runs if tests pass
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to GitHub Container Registry
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha # reuse Docker layer cache across runs
cache-to: type=gha,mode=max
Note how the test job uses a services: Postgres with a health check, and build-and-push declares needs: test so packaging never happens on a red build. The image is tagged with github.sha — immutable and traceable.
Packaging backends as containers
Containers are the dominant packaging format for backend services: they bundle your app with its exact runtime and dependencies, so it runs identically on a laptop, in CI, and in production. See ../kubernetes for orchestrating them at scale.
Image best practices:
- Multi-stage builds — compile in a fat “builder” stage, copy only the final binary/artifact into a slim runtime stage. The shipped image stays tiny.
- Small base image —
distroless,alpine, or-slimvariants. Less to download, smaller attack surface. - Run as non-root — create an unprivileged user; never run the process as
root. - Pin versions — pin the base image (ideally by digest) and dependency versions for reproducibility.
- Leverage layer caching — copy the dependency manifest and install before copying source, so code changes don’t invalidate the dependency layer.
.dockerignore— keep.git, tests, and secrets out of the build context.
# ---- build stage ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # cached unless go.mod/go.sum change
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
# ---- runtime stage ----
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app /app
USER nonroot:nonroot # non-root
EXPOSE 8080
ENTRYPOINT ["/app"]
The final image contains only the static binary and a minimal base — no compiler, no shell, no package manager.
Deployment environments & configuration
Backends flow through a chain of environments, each closer to real:
| Environment | Purpose | Data | Who uses it |
|---|---|---|---|
| dev / local | Fast inner loop while coding | Fake/seed | The developer |
| staging / pre-prod | Final verification, mirrors prod | Prod-like (anonymized) | QA, automated tests |
| production | Real traffic | Real | End users |
The same immutable artifact moves through all of them; only configuration changes between environments. This is the Twelve-Factor App principle: store config in the environment, not in the code. Database URLs, feature flags, and log levels come from environment variables — the same binary connects to the dev DB or the prod DB depending only on DATABASE_URL.
Secrets in CI/CD. API keys, DB passwords, and signing keys must never live in the repo. Store them in the CI provider’s encrypted secret store (GitHub Actions Secrets, Vault, cloud secret managers) and inject them as environment variables at deploy time. In the pipeline, mask them in logs and scope them narrowly (per-environment, least privilege). See security and Auth.
Deployment strategies
How you replace the old version with the new one determines your blast radius and rollback speed.
| Strategy | How it works | Downtime | Rollback | Cost | Best for |
|---|---|---|---|---|---|
| Recreate | Stop old, start new | Yes (brief) | Re-deploy old | Low | Dev, or apps that tolerate downtime |
| Rolling | Replace instances a few at a time | None | Roll back gradually | Low | Default for stateless services / Kubernetes |
| Blue-green | Run two full environments; switch traffic at once | None | Instant (switch back) | High (2x infra) | Fast, atomic cutover with easy rollback |
| Canary | Send a small % of traffic to the new version, then ramp | None | Stop the ramp, route back | Medium | High-risk changes; needs good metrics |
| Feature flags | Ship code dark, toggle features on per-user/% at runtime | None | Flip the flag off | Low | Decoupling deploy from release; A/B tests |
- Rolling is the default in Kubernetes: it gradually swaps pods while keeping the service available, gated by readiness probes.
- Blue-green gives an instant, atomic switch and instant rollback, at the cost of running two full stacks. Beware shared databases — both colors must be schema-compatible.
- Canary is the safest for risky changes: expose 1% → 5% → 25% → 100% while watching error rate and latency; automatically halt and roll back if metrics degrade. This is progressive delivery.
- Feature flags decouple deploying code from releasing a feature. You merge and deploy incomplete work behind an off flag, then turn it on independently — and off instantly if it misbehaves — without a redeploy.
Hosting / runtime targets for backends
Where does the artifact actually run? The spectrum trades control for operational burden.
| Target | You manage | Scaling | Fits when |
|---|---|---|---|
| VMs / bare metal | OS, runtime, app, scaling | Manual / autoscaling groups | Full control, legacy, special hardware, predictable load |
| Containers + orchestrator (Kubernetes) | App + manifests; platform handles scheduling | Horizontal, automated | Many services, need portability & self-healing at scale |
| PaaS (Heroku, Render, Fly.io, App Engine) | Just your app | Automatic | Small teams wanting speed over control |
| Serverless / FaaS (Lambda, Cloud Functions) | Just the function code | Automatic, to zero | Event-driven, spiky, or low/irregular traffic |
Move down the table for less ops toil, up for more control. See ../kubernetes for the container-orchestration path.
Serverless for backends
FaaS (Functions as a Service) runs your code in response to events (HTTP request, queue message, cron) without you managing servers. The platform scales instances up and down — even to zero — and you pay per invocation.
- Statelessness is mandatory. Functions are ephemeral; keep no in-memory state between invocations. Persist state in a database, cache, or object store.
- Cold starts. When no warm instance exists, the platform must initialize a new one, adding latency (tens of ms to seconds, worse for JVM/heavy runtimes). Mitigate with lighter runtimes, smaller packages, and provisioned concurrency.
- Connection management. Traditional DB connection pools fit poorly — thousands of function instances can exhaust DB connections. Use a proxy/pooler (e.g. RDS Proxy, PgBouncer) or an HTTP-based data API.
- Good fit: event processing, webhooks, scheduled jobs, glue code, spiky/unpredictable traffic, and APIs where paying-per-request beats idle servers.
- Poor fit: long-running jobs (execution time limits), latency-critical low-jitter paths (cold starts), and workloads with steady high volume (a always-on container is often cheaper).
Database migrations in the pipeline
Backends have state, and schema changes are the trickiest part of deployment. A migration is a versioned, ordered change to the schema (create table, add column, backfill data), applied by a tool like Flyway, Liquibase, Alembic, or golang-migrate.
Run migrations as an explicit pipeline step before (or alongside) the app deploy, not from inside the app at startup (which races across replicas). The critical rule for zero-downtime: migrations must be backward-compatible — the old and new code versions both run against the transitional schema.
Use the expand-and-contract (parallel change) pattern for breaking changes, e.g. renaming a column:
- Expand — add the new column; deploy code that writes to both old and new.
- Backfill — copy existing data into the new column.
- Migrate reads — deploy code that reads from the new column.
- Contract — once nothing uses the old column, drop it in a later deploy.
Each step is independently deployable and rollback-safe. Never combine a destructive schema change with the code deploy that depends on it.
Zero-downtime deploys and rollbacks
Zero-downtime means users never see an error during a release. It requires:
- A deployment strategy that keeps capacity serving (rolling / blue-green / canary).
- Health checks / readiness probes — the platform only routes traffic to an instance once it reports healthy, and stops routing before shutting one down.
- Graceful shutdown — on
SIGTERM, stop accepting new requests, finish in-flight ones, then exit. Combined with connection draining, no request is dropped. - Backward-compatible migrations (above) so both versions coexist.
Rollback is your safety net. Because artifacts are immutable and versioned, rolling back is redeploying the previous known-good image tag. Make it a first-class, fast, one-command (or automatic) operation. Note the asymmetry: code rolls back cleanly; data does not. A schema migration that dropped a column cannot be un-run without data loss — which is exactly why expand-and-contract keeps destructive changes separate and late.
Release safety and progressive delivery
Ship safely by layering gates and guardrails:
- Automated tests as gates — the pipeline blocks any change that fails unit/integration tests, linting, or security scans. This is your first and cheapest defense (Testing).
- Health checks — liveness and readiness endpoints let the platform detect and replace sick instances automatically.
- Progressive delivery — canary + feature flags expose changes to a shrinking-blast-radius slice first, so a bad release affects 1% of traffic, not 100%.
- Observability tie-in — automated rollout should watch real signals: error rate, latency (p95/p99), saturation. If they regress during a canary, halt and roll back automatically. This closes the loop with Observability & Monitoring — you can’t do safe progressive delivery without good metrics, logs, and traces to judge each step.
Best Practices
- Build once, deploy many. Promote the same immutable, SHA-tagged artifact through dev → staging → prod. Never rebuild per environment.
- Keep the pipeline fast. Fast feedback (aim for under ~10 minutes to green) keeps developers merging frequently. Cache dependencies and Docker layers, and parallelize with matrix builds.
- Make tests the gate. No deploy on a red build. Run unit + integration + linters + security scans automatically on every PR.
- Store config in the environment, secrets in a secret manager. Twelve-factor config; never commit secrets; inject them at deploy time with least privilege.
- Package as small, non-root, multi-stage container images, pinned by digest.
- Choose a zero-downtime strategy (rolling/blue-green/canary) with readiness probes and graceful shutdown; avoid recreate in production.
- Make migrations backward-compatible and run them as an explicit step; use expand-and-contract for breaking changes; never couple a destructive migration to the code that needs it.
- Decouple deploy from release with feature flags — ship dark, turn on gradually, kill instantly.
- Make rollback trivial and fast — one command or automatic; test it. Remember data doesn’t roll back like code.
- Automate everything and keep it in version control — pipeline definitions (YAML) live in the repo and are reviewed like any other code.
- Gate progressive rollouts on real metrics — tie canary promotion/abort to error rate and latency from your observability stack.
References
- roadmap.sh/backend — the backend roadmap this note follows
- Martin Fowler: Continuous Integration
- Martin Fowler: Continuous Delivery & Blue-Green Deployment
- The Twelve-Factor App
- GitHub Actions Documentation
- Docker: Best practices for building images
- Kubernetes: Deployments & rolling updates
- AWS: Blue/Green Deployments (Whitepaper)