CI/CD ›
Securing pipelines, GitOps and Infrastructure as Code, observability, and the advanced topics that keep delivery reliable at scale.
Security in CI/CD
Secrets Management
Bad Practice:
deploy:
script:
- API_KEY="sk-1234567890" npm run deploy # Never do this!
Good Practice:
deploy:
script:
- npm run deploy
environment:
name: production
secrets:
API_KEY:
from_secret: api_key_production
Security Scanning Pipeline
security-scan:
stage: security
parallel:
- dependency-check:
script:
- npm audit
- pip-audit # Python (alternative to safety)
- bundle audit # Ruby
- osv-scanner --recursive . # Google's OSV Scanner
- sast: # Static Application Security Testing
script:
- semgrep --config=auto
- bandit -r src/ # Python
- snyk code test
- container-scan:
script:
- trivy image myapp:latest
- grype myapp:latest # Anchore scanner
- docker scout cves myapp:latest # Docker's native scanner
- secrets-scan:
script:
- gitleaks detect --source=.
- trufflehog filesystem . --json
Security Best Practices
-
Rotate Secrets Regularly
```yaml
- name: Check secret age run: | if [ $(secret_age $SECRET_NAME) -gt 90 ]; then echo “::error::Secret older than 90 days!” exit 1 fi ```
-
Least Privilege Access
deploy: permissions: contents: read deployments: write # Only what's needed, nothing more -
Audit Logs
```yaml
after_script:
- echo “Deployed by $CI_USER at $CI_TIMESTAMP” » audit.log ```
Software Supply-Chain Security
The classic threat model assumes attackers come through the front door — the running application. Supply-chain attacks come through the build: a compromised dependency (SolarWinds, event-stream, the xz backdoor), a poisoned base image, a stolen signing key, or a tampered build step that injects malware into an otherwise-legitimate artifact. Because the resulting binary is signed and shipped through normal channels, downstream consumers trust it implicitly. Supply-chain security closes this gap by making the provenance of every artifact verifiable: what source produced it, which builder ran, and what it depends on — all cryptographically attested and checked before deployment.
The pieces fit together as a chain of evidence:
source ──build──▶ artifact ──sign──▶ signature
│ │ │
└─ dependency └─ SBOM └─ provenance attestation
scanning (bill of (SLSA: who/how/from-what)
materials)
│
admission/verify gate ──▶ deploy
SLSA: Levels of Build Integrity
SLSA (Supply-chain Levels for Software Artifacts, pronounced “salsa”) is a framework that grades how trustworthy an artifact’s build process is. It is not a tool — it is a set of requirements you satisfy by hardening your pipeline. The current v1.0 track focuses on build provenance:
| Level | Requirement | What it stops |
|---|---|---|
| L0 | No guarantees | Nothing |
| L1 | Provenance exists — build emits a signed record of how the artifact was produced | Mistakes; “where did this binary come from?” |
| L2 | Provenance is signed by a hosted build platform; source and build are version-controlled | Tampering with provenance after the fact |
| L3 | Build runs on a hardened, isolated platform; provenance is non-forgeable (signed by the platform, not the build script) | A malicious build step forging its own provenance; cross-build contamination |
The key idea at L3 is isolation: the build environment cannot reach the signing key, so even a fully compromised build script cannot forge a valid provenance attestation. GitHub-hosted runners with the slsa-framework/slsa-github-generator reusable workflow reach L3 because the signing happens in a separate, trusted reusable workflow your job cannot tamper with.
# SLSA L3 provenance via the official GitHub generator
jobs:
build:
outputs:
digest: ${{ steps.hash.outputs.digest }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make release # produces ./dist/app
- id: hash
run: echo "digest=$(sha256sum dist/app | base64 -w0)" >> "$GITHUB_OUTPUT"
provenance:
needs: [build]
permissions:
actions: read # read the workflow run
id-token: write # OIDC token for keyless signing
contents: write # attach provenance to the release
# The reusable workflow runs isolated from your build job — this is what
# makes the provenance non-forgeable and earns L3.
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0
with:
base64-subjects: ${{ needs.build.outputs.digest }}
Artifact Signing with Cosign and Sigstore
Signing answers “is this the exact artifact the build produced, and did we publish it?” Traditionally this meant long-lived GPG/PGP keys that teams had to store, rotate, and inevitably leak. Sigstore replaces long-lived keys with keyless signing: an ephemeral key pair is minted per-signature, certified against an OIDC identity (your GitHub Actions workload identity, a Google account, etc.) by the Fulcio CA, and the signing event is recorded in Rekor, a public tamper-evident transparency log. The private key is discarded seconds later — there is nothing long-lived to steal.
cosign is the CLI that drives this for container images and arbitrary blobs:
sign-image:
permissions:
id-token: write # Sigstore OIDC keyless signing
packages: write # push the signature to the registry
steps:
- uses: sigstore/cosign-installer@v3
# Keyless: identity comes from the GitHub Actions OIDC token,
# certificate from Fulcio, signature logged in Rekor.
- name: Sign the image
run: cosign sign --yes ghcr.io/myorg/app@${IMAGE_DIGEST}
# Verify before deploy — pin the expected identity and issuer so a
# signature from any other workflow is rejected.
- name: Verify
run: |
cosign verify ghcr.io/myorg/app@${IMAGE_DIGEST} \
--certificate-identity-regexp '^https://github.com/myorg/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Always sign and verify by digest (
@sha256:...), never by tag. Tags are mutable —:latestcan be repointed at a malicious image after you sign the tag. The digest is the content hash, so a signature over a digest is a signature over exactly those bytes. See Docker Registry & Distribution for how digests, tags, and content trust work at the registry level.
SBOM Generation and Attestation
A Software Bill of Materials is a complete, machine-readable inventory of every component in an artifact — direct and transitive dependencies, versions, licenses, and hashes. When the next Log4Shell-class CVE drops, the question “are we affected, and where?” becomes a query against stored SBOMs instead of a frantic codebase audit. The two dominant formats are SPDX (ISO standard, license-focused) and CycloneDX (OWASP, security-focused); most tools emit both.
sbom:
steps:
# Generate an SBOM from the built image (syft works on images, dirs, archives)
- run: |
syft ghcr.io/myorg/app@${IMAGE_DIGEST} \
-o spdx-json=sbom.spdx.json \
-o cyclonedx-json=sbom.cdx.json
# Attach the SBOM to the image as a signed attestation, not a loose file.
# An *attestation* binds the SBOM to the image digest and signs the pair,
# so consumers can verify "this SBOM really describes this image."
- run: |
cosign attest --yes \
--predicate sbom.spdx.json \
--type spdxjson \
ghcr.io/myorg/app@${IMAGE_DIGEST}
# Scan the SBOM for known vulnerabilities (decoupled from generation —
# you can re-scan old SBOMs as new CVEs are published, without rebuilding)
- run: grype sbom:sbom.cdx.json --fail-on high
The distinction between a file and an attestation matters: a sbom.json sitting in build artifacts is unsigned and unbound — anyone can swap it. cosign attest signs the SBOM-plus-digest as a unit and stores it alongside the image in the registry (and in Rekor), so cosign verify-attestation can later prove the SBOM is authentic and describes that exact image.
Dependency and Secret Scanning in Pipelines
Provenance proves who built it; scanning proves it isn’t already known to be bad. Both run as gates — failing the build rather than merely warning — so a vulnerable dependency or a leaked credential never reaches a registry.
supply-chain-gate:
stage: security
parallel:
- dependency-vulns:
script:
# SCA against your lockfiles + the image's OS packages
- osv-scanner --recursive .
- trivy image --severity HIGH,CRITICAL --exit-code 1 ${IMAGE}
- secret-scanning:
script:
# Scan full git history, not just the working tree — a secret
# committed and "removed" still lives in earlier commits.
- gitleaks detect --source=. --redact
- trufflehog git file://. --since-commit HEAD~50 --only-verified
- license-policy:
script:
# Block copyleft/forbidden licenses pulled in transitively
- trivy image --scanners license --severity HIGH ${IMAGE}
Push-time scanning is necessary but reactive; complement it with pre-receive enforcement (pre-commit gitleaks hooks so secrets are caught before they ever land in history) and continuous re-scanning of already-published images, since new CVEs are disclosed against artifacts that scanned clean yesterday. Registry-side scanning (Trivy/Grype integrated into the registry, or native scanners like Harbor’s) provides this last line of defense — see Docker Registry & Distribution for registry-integrated scanning and admission policy.
Secret Rotation
Even well-managed secrets must be assumed to leak eventually; rotation bounds the blast radius by limiting how long a leaked credential is useful. The progression of maturity is:
- Static long-lived secrets, manually rotated — the floor. Track each secret’s age and fail the pipeline past a policy threshold (the 90-day check shown earlier).
- Automated rotation via a secrets manager — Vault, AWS Secrets Manager, or GCP Secret Manager rotates the credential on a schedule and re-issues it to consumers, so no human handles the value.
- Short-lived dynamic secrets — the secrets manager mints a credential on demand with a TTL of minutes (e.g. Vault’s database secrets engine creates a per-job DB user that auto-expires). There is no standing secret to rotate.
- No secrets at all — workload identity / OIDC federation — the pipeline presents a short-lived OIDC token proving its identity, and the cloud provider exchanges it for scoped, temporary credentials. This is strictly better than rotation: nothing long-lived exists to leak.
# Keyless cloud auth via OIDC — no stored AWS keys to rotate or leak.
deploy:
permissions:
id-token: write # mint the OIDC token
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy-role
aws-region: us-east-1
# No aws-access-key-id / aws-secret-access-key — STS issues
# temporary credentials in exchange for the OIDC token.
# Short-lived dynamic DB credential from Vault (TTL=1h, auto-revoked)
- run: |
export VAULT_TOKEN=$(vault write -field=token \
auth/jwt/login role=ci jwt="$CI_JOB_JWT")
creds=$(vault read -format=json database/creds/app-readonly)
export DB_USER=$(echo "$creds" | jq -r .data.username)
export DB_PASS=$(echo "$creds" | jq -r .data.password)
./run-migrations.sh
# Lease expires automatically; nothing persists past the job.
When a static secret must remain, rotate without downtime by overlapping validity: provision the new credential, deploy consumers configured to accept both old and new, then revoke the old one once every consumer has rolled. The same dual-key window applies to signing keys — though Sigstore’s keyless model (above) sidesteps signing-key rotation entirely by never holding a long-lived key.
Monitoring and Observability
Pipeline Metrics
collect-metrics:
after_script:
- |
# Send metrics to monitoring system
curl -X POST https://metrics.api/pipeline \
-d '{
"pipeline": "$CI_PIPELINE_ID",
"duration": "$CI_PIPELINE_DURATION",
"status": "$CI_JOB_STATUS",
"branch": "$CI_COMMIT_BRANCH"
}'
Key Metrics to Track
- Lead Time: Commit to production
- Deployment Frequency: Deploys per day/week
- MTTR: Mean Time To Recovery
- Change Failure Rate: Failed deploys percentage
Observability Dashboard Example
# Grafana dashboard query
deployment_frequency:
query: |
count(
ci_pipeline_status{status="success", branch="main"}
) by (day)
lead_time_p95:
query: |
histogram_quantile(0.95,
ci_pipeline_duration_seconds_bucket
)
GitOps
With GitOps, Git is the single source of truth for both application and infrastructure state. Developers commit changes; a GitOps operator (ArgoCD, Flux) continuously reconciles the cluster to match the repository.
The GitOps Workflow
# 1. Developer commits code
git add .
git commit -m "feat: add payment processing"
git push origin feature/payments
# 2. CI pipeline runs tests
ci-pipeline:
- test
- build
- push-image
# 3. Update deployment manifest
update-manifest:
script:
- git clone https://github.com/myorg/k8s-configs
- cd k8s-configs
- yq eval '.image.tag = "'$CI_COMMIT_SHA'"' -i app/values.yaml
- git commit -am "Update app to $CI_COMMIT_SHA"
- git push
# 4. GitOps operator syncs
# ArgoCD/Flux automatically deploys changes
The same loop works for Kubernetes manifests authored with yq/Helm and for declarative Infrastructure as Code (Terraform state committed to Git), so application and infrastructure changes flow through one reviewed, auditable history.
GitOps Best Practices
-
Separate Config Repo
app-code/ # Application source app-config/ # Kubernetes manifests app-secrets/ # Encrypted secrets (using Sealed Secrets/SOPS) -
Environment Branches
main → production/ staging → staging/ develop → development/ -
Automated Rollback
on-failure: script: - git revert HEAD - git push # GitOps operator automatically rolls back
Modern GitOps Tools:
- ArgoCD: Most popular, great UI, multi-cluster support (with ApplicationSets)
- Flux v2: GitOps toolkit, native Kubernetes controller
- Rancher Fleet: Multi-cluster GitOps at scale
- Weave GitOps: Enterprise features, policy management
Infrastructure as Code Integration
Terraform in CI/CD
terraform-pipeline:
stages:
- validate
- plan
- apply
validate:
script:
- terraform init
- terraform validate
- terraform fmt -check
plan:
script:
- terraform plan -out=tfplan
artifacts:
paths:
- tfplan
apply:
script:
- terraform apply tfplan
when: manual
only:
- main
Ansible Integration
ansible-deploy:
script:
- ansible-playbook -i inventory/production deploy.yml
before_script:
- ansible-galaxy install -r requirements.yml
- ansible-lint playbooks/
Common Pitfalls and Troubleshooting
1. Flaky Tests
Problem: Tests pass locally but fail in CI
Solutions:
// Bad: Time-dependent test
it('expires after 1 hour', async () => {
await sleep(3600000); // Don't do this!
expect(isExpired()).toBe(true);
});
// Good: Mock time
it('expires after 1 hour', async () => {
const clock = sinon.useFakeTimers();
clock.tick(3600000);
expect(isExpired()).toBe(true);
clock.restore();
});
2. Secret Leaks
Problem: Accidentally committed secrets
Prevention:
pre-commit-check:
script:
- gitleaks detect --source=. --verbose
- detect-secrets scan --all-files
3. Long Build Times
Problem: Pipeline takes hours
Solutions:
# Cache dependencies
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- .npm/
# Parallel jobs
test:
parallel: 4
script:
- npm run test:chunk:${CI_NODE_INDEX}
# Incremental builds
build:
script:
- npm run build --since=$CI_COMMIT_BEFORE_SHA
4. Environment Drift
Problem: “Works in staging, breaks in production”
Solution:
# Use identical environments
.deploy_template: &deploy_template
image: deploy:v1.2.3
variables:
TERRAFORM_VERSION: "1.5.0"
KUBECTL_VERSION: "1.27.0"
deploy_staging:
<<: *deploy_template
environment: staging
deploy_production:
<<: *deploy_template
environment: production
Real-World Examples
Example 1: E-commerce Platform
Challenge: Deploy updates without affecting active shoppers
Solution:
name: E-commerce Deployment
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# 1. Build and test
- uses: actions/checkout@v4
- run: |
docker build -t shop:$GITHUB_SHA .
docker run shop:$GITHUB_SHA npm test
# 2. Deploy to canary (5% traffic)
- name: Canary Deploy
run: |
kubectl set image deployment/shop-canary \
shop=shop:$GITHUB_SHA -n production
# 3. Monitor metrics
- name: Monitor Canary
run: |
./scripts/monitor-canary.sh --duration=30m \
--error-threshold=1% \
--latency-p99=200ms
# 4. Full rollout
- name: Production Deploy
run: |
kubectl set image deployment/shop \
shop=shop:$GITHUB_SHA -n production
Example 2: Microservices Platform
Challenge: Coordinate deployment of 50+ services
Solution:
# Monorepo CI/CD
name: Microservices Pipeline
on:
push:
branches: [main]
jobs:
detect-changes:
outputs:
services: ${{ steps.filter.outputs.changes }}
steps:
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
auth: services/auth/**
payment: services/payment/**
inventory: services/inventory/**
# ... 47 more services
build-and-deploy:
needs: detect-changes
strategy:
matrix:
service: ${{ fromJson(needs.detect-changes.outputs.services) }}
steps:
- name: Build Service
run: |
cd services/${{ matrix.service }}
docker build -t ${{ matrix.service }}:$GITHUB_SHA .
- name: Deploy Service
run: |
helm upgrade --install ${{ matrix.service }} \
./charts/${{ matrix.service }} \
--set image.tag=$GITHUB_SHA \
--wait --timeout=5m
Example 3: Mobile App Deployment
Challenge: Deploy to multiple app stores with different requirements
Solution:
name: Mobile App Release
on:
push:
tags:
- 'v*'
jobs:
build:
strategy:
matrix:
platform: [ios, android]
steps:
- uses: actions/checkout@v4
- name: Build ${{ matrix.platform }}
run: |
if [ "${{ matrix.platform }}" == "ios" ]; then
fastlane ios build
else
fastlane android build
fi
- name: Run Tests
run: |
if [ "${{ matrix.platform }}" == "ios" ]; then
fastlane ios test
else
fastlane android test
fi
deploy:
needs: build
steps:
- name: Deploy to App Store
run: fastlane ios release
- name: Deploy to Play Store
run: fastlane android release
- name: Notify Team
run: |
curl -X POST $SLACK_WEBHOOK \
-d '{"text":"Version ${{ github.ref }} released to app stores!"}'
Advanced Topics
Self-Hosted Runners
# Setup for high-security environments
self-hosted-runner:
runs-on: [self-hosted, linux, x64, gpu]
container:
image: custom-runner:latest
options: --gpus all
steps:
- name: ML Model Training
run: python train.py --gpu --distributed
Pipeline as Code Libraries
// Jenkins Shared Library
@Library('company-pipeline-lib') _
companyPipeline {
language = 'java'
type = 'microservice'
deployEnvironments = ['dev', 'staging', 'prod']
slackChannel = '#deployments'
}
Multi-Cloud Deployments
multi-cloud-deploy:
strategy:
matrix:
cloud: [aws, azure, gcp]
steps:
- name: Deploy to ${{ matrix.cloud }}
run: |
case "${{ matrix.cloud }}" in
aws)
terraform apply -var-file=aws.tfvars
;;
azure)
terraform apply -var-file=azure.tfvars
;;
gcp)
terraform apply -var-file=gcp.tfvars
;;
esac
Emerging Trends in CI/CD
-
AI-Powered CI/CD
- Predictive test selection
- Automated flaky test detection
- AI-generated pipeline optimizations
- Smart deployment timing
-
Supply Chain Security
- SBOM (Software Bill of Materials) generation
- SLSA compliance automation
- Sigstore for artifact signing
- Dependency attestation
-
Platform Engineering
- Internal Developer Platforms (IDPs)
- Golden paths for deployment
- Self-service infrastructure
- Developer experience metrics
-
Green CI/CD
- Carbon-aware computing
- Energy-efficient build scheduling
- Resource optimization
- Sustainability metrics
Resources and Further Learning
Essential Tools
-
Pipeline Syntax Validators:
- GitHub Actions playground
- GitLab CI Lint
- CircleCI Config Validator
-
Security Scanners:
- Snyk (now with AI-powered fixes)
- SonarQube/SonarCloud
- Checkmarx
- GitHub Advanced Security
-
Monitoring:
- Datadog CI Visibility
- New Relic CodeStream
- Grafana Cloud
- OpenTelemetry (standard for observability)
-
GitOps Operators:
- ArgoCD (with ApplicationSets)
- Flux v2
- Crossplane (infrastructure composition)
Books and Courses
- “Continuous Delivery” by Jez Humble (Classic)
- “The DevOps Handbook” by Gene Kim et al.
- “Accelerate” by Nicole Forsgren et al.
- “Modern Software Engineering” by David Farley (2022)
- “The Phoenix Project” & “The Unicorn Project” by Gene Kim
Online Learning
- DevOps with GitLab CI - GitLab’s official course
- GitHub Actions Deep Dive - A Cloud Guru
- Jenkins 2023 Masterclass - Udemy
- CNCF CI/CD with Tekton - Linux Foundation
Community Resources
- CNCF CI/CD Landscape
- DevOps Weekly Newsletter
- CI/CD Collective Forum
Adopting CI/CD is incremental work: start simple, measure everything, and refine the pipeline based on what the metrics show.
See Also
- Platforms & Pipeline Design — choosing a platform and structuring pipelines
- Deployment Strategies — blue-green, canary, and rolling rollouts
- Terraform — infrastructure as code for the IaC pipelines above
- Cybersecurity — securing the pipeline and its secrets
- Docker Registry & Distribution — image signing, SBOMs, and provenance at the registry level