CI/CD
#5 8 pagesCI/CD: Concepts & Patterns
CI vs CD vs GitOps, how pipelines trigger, the deployment strategies you'll actually choose between, and how secrets stay out of your repo — with a knowledge check after each section.
CI vs CD vs GitOps
graph LR
classDef ci fill:#3498db,stroke:#2980b9,color:#fff
classDef cd fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef gitops fill:#2ecc71,stroke:#27ae60,color:#fff
classDef artifact fill:#e67e22,stroke:#d35400,color:#fff
subgraph CI["CI: Continuous Integration"]
COMMIT["Code pushed"]:::ci --> BUILD["Build + compile"]:::ci
BUILD --> TEST["Unit + integration tests"]:::ci
TEST --> LINT["Lint + security scan"]:::ci
LINT --> ART["Artifact: Docker image, binary"]:::artifact
end
subgraph CD["CD: Continuous Delivery"]
ART2["Artifact"]:::artifact --> STAGING["Deploy to staging"]:::cd
STAGING --> SMOKE["Smoke tests"]:::cd
SMOKE --> PROD["Deploy to prod"]:::cd
end
subgraph GitOps["GitOps: Declarative CD"]
GIT["Git = source of truth"]:::gitops --> AGENT["ArgoCD/Flux watches repo"]:::gitops
AGENT --> SYNC["Syncs cluster to match git"]:::gitops
SYNC --> HEAL["Detects and corrects drift"]:::gitops
end
| CI | CD push | GitOps pull | |
|---|---|---|---|
| Trigger | Code push/PR | Pipeline pushes to env | Agent pulls from git |
| Audit | Pipeline logs | Pipeline logs | Git commits |
| Rollback | Re-run pipeline | Re-run pipeline | git revert + auto-sync |
The key difference between CD push and GitOps pull isn't the end state — both land the same artifact in the same cluster. It's who initiates the change and who holds the credentials. A CD pipeline needs deploy credentials for every environment it targets. A GitOps agent runs inside the cluster and only needs read access to git — nothing external ever gets a key to your production cluster.
Under GitOps, who actually pushes the new version into the cluster — the CI pipeline, or something else?
Pipeline Triggers
graph TD
classDef pr fill:#e67e22,stroke:#d35400,color:#fff
classDef push fill:#2ecc71,stroke:#27ae60,color:#fff
classDef gate fill:#e74c3c,stroke:#c0392b,color:#fff
subgraph PRTrigger["PR trigger: gates before merge"]
PR_OPEN["Developer opens PR"]:::pr --> RUN_TESTS["Run: lint, tests, security scan, build"]:::pr
RUN_TESTS --> GATE["Gate: must pass before merge allowed"]:::gate
end
subgraph PushTrigger["Push trigger: deploy after merge"]
MERGE["Merge to main"]:::push --> DEPLOY_STG["Deploy to staging"]:::push
DEPLOY_STG --> TAG["Git tag: v1.2.3"]:::push
TAG --> DEPLOY_PROD["Deploy to prod"]:::push
end
Best practice flow — the two trigger types above, chained end to end:
graph LR
classDef trig fill:#e67e22,stroke:#d35400,color:#fff
classDef action fill:#2ecc71,stroke:#27ae60,color:#fff
classDef gate fill:#e74c3c,stroke:#c0392b,color:#fff
T1["PR opened"]:::trig --> A1["CI: lint + test + security scan + build"]:::action
T2["PR merged to main"]:::trig --> A2["CD: deploy to staging + smoke tests"]:::action
T3["Git tag v*.*.*"]:::trig --> A3["CD: deploy to production"]:::action --> G1["Approval gate"]:::gate
Notice the asymmetry: a PR only ever earns the right to merge, it never deploys anything by itself. Deployment only starts once code lands on main, and production specifically waits for a tag plus a human approval — three separate triggers doing three separate jobs, so a bad PR can't accidentally ship itself to prod.
A feature-branch push and a merge to main both run the pipeline. Should they trigger the same stages?
main is what should trigger CD: deploy to staging, run smoke tests. Collapsing the two means an unreviewed branch could deploy itself, which defeats the point of having a gate at all.Deployment Strategies
graph TD
classDef v1 fill:#3498db,stroke:#2980b9,color:#fff
classDef v2 fill:#2ecc71,stroke:#27ae60,color:#fff
classDef switch fill:#e67e22,stroke:#d35400,color:#fff
classDef info fill:#95a5a6,stroke:#7f8c8d,color:#fff
subgraph Rolling["Rolling Update: gradual replacement"]
R1["v1: 3 pods"]:::v1 --> R2["Replace 1 pod with v2"]:::v2
R2 --> R3["Replace next"]:::v2
R3 --> R4["All on v2"]:::v2
RN["Low cost. Both versions run briefly. Slower rollback."]:::info
end
subgraph BlueGreen["Blue-Green: instant switch"]
BG1["Blue: v1 100% traffic"]:::v1 --> BG2["Green: v2 idle"]:::v2
BG2 --> BG3["Switch LB to Green"]:::switch
BG3 --> BG4["Blue kept for instant rollback"]:::v1
BGN["Instant rollback. Doubles cost during transition."]:::info
end
subgraph Canary["Canary: gradual traffic shift"]
C1["v1: 95%"]:::v1 --> C2["v2: 5% canary"]:::v2
C2 --> C3["Monitor error rate + p99 latency"]:::switch
C3 --> C4["Shift: 10% to 25% to 50% to 100%"]:::v2
CN["Real user validation. Low blast radius."]:::info
end
Same three strategies, side by side — flip through to compare rollback, cost, and risk directly instead of scanning a table:
Canary in particular isn't a single event — it's a process that unfolds over minutes or hours. Walk through what actually happens after you ship:
Blue-green gives "instant rollback" but "doubles cost." Why can't you tear Blue down as soon as Green takes traffic, to avoid paying for both?
Secrets in CI/CD
graph LR
classDef bad fill:#e74c3c,stroke:#c0392b,color:#fff
classDef good fill:#2ecc71,stroke:#27ae60,color:#fff
BAD["Secrets in code or CI config as plaintext"]:::bad -->|"never"| GOOD["Secure store: pipeline fetches at runtime, nothing committed"]:::good
Four tiers of "secure store," roughly in order of how production-grade they are — flip through:
SOPS + KMS commits an encrypted secret straight into git history. Is that as risky as committing the secret in plaintext?
Docker Layer Caching in CI
graph TD
classDef cached fill:#2ecc71,stroke:#27ae60,color:#fff
classDef rebuild fill:#e74c3c,stroke:#c0392b,color:#fff
classDef base fill:#3498db,stroke:#2980b9,color:#fff
subgraph Good["Optimised: deps before source"]
G1["FROM golang:1.23-alpine"]:::base --> G2["COPY go.mod go.sum + RUN go mod download"]:::cached
G2 --> G3["COPY source code"]:::rebuild
G3 --> G4["RUN go build"]:::rebuild
GN["go.mod unchanged = layer 2 cached. Only layers 3-4 rebuild."]:::cached
end
subgraph Bad["Wrong order: source before deps"]
B1["FROM golang:1.23-alpine"]:::base --> B2["COPY . ."]:::rebuild
B2 --> B3["RUN go mod download"]:::rebuild
B3 --> B4["RUN go build"]:::rebuild
BN["Every commit invalidates the dep download layer."]:::rebuild
end
With the "optimised" ordering, a teammate changes only application source code — no dependency changes. Does the go mod download layer rebuild?
go.mod/go.sum are unchanged, the COPY go.mod go.sum and RUN go mod download layers are reused straight from cache. Only the layers whose inputs actually changed — COPY source and RUN go build — rebuild.Debugging Flaky Pipelines
graph TD
classDef check fill:#3498db,stroke:#2980b9,color:#fff
classDef cause fill:#e74c3c,stroke:#c0392b,color:#fff
classDef fix fill:#2ecc71,stroke:#27ae60,color:#fff
FLAKY["Pipeline fails intermittently"]:::cause --> P1["1. Pattern? Time of day, branch, specific stage?"]:::check
P1 --> P2["2. Env secrets correctly configured per environment?"]:::check
P2 --> P3["3. Race conditions? Parallel jobs competing for same resource?"]:::check
P3 --> P4["4. Artifact transfer between stages? Flaky storage?"]:::check
P4 --> P5["5. Deployment target K8s/ECS throwing transient errors?"]:::check
P5 --> P6["6. Add explicit retry logic and better logging"]:::fix
Same checklist, one question at a time — useful mid-incident, when the whole tree at once is more to parse than you want:
A pipeline fails about 1 time in 20, always on a stage that runs two jobs in parallel against the same test database. Which check does this point to?