Design CI/CD pipelines that are fast, reliable, and secure. Covers pipeline architecture, caching strategies, parallel execution, security scanning, deployment strategies, and the patterns that prevent your pipeline from becoming the bottleneck.
A CI/CD pipeline design that delivers code from push to production in minutes—not days—turns deployment from an event into a continuous flow. This design is essential when teams ship multiple times a day, require zero-downtime releases, and expect immediate feedback from production issues. It matters when release coordination across teams is manual, when deployments are error-prone, and when a failed release causes cascading outages. The goal is to automate every step from code commit to feature visibility in production, with clear signals for success and failure at each stage.
A pipeline that moves in minutes is built on five principles: atomicity, idempotency, observability, parallelism, and incrementalism.
These principles are enforced through consistent conventions across the pipeline. The pipeline is defined as code, stored in ci/pipeline.yaml or tekton/pipeline.yaml, and executed by a real orchestrator—such as GitHub Actions, GitLab CI, Argo CD, or Tekton—using standard tools.
Every pipeline has a predictable lifecycle: commit → build → test → deploy → monitor.
Every change starts with a Git commit. The commit message must follow a conventional format:
feat(auth): add email verification
- Add email verification step after registration
- Send confirmation email with token link
- Validate token in API route /auth/verify/:token
Closes #142
This format enables automatic versioning and changelog generation. The pipeline triggers on git push origin main, and only the changed files are considered for incremental builds.
The pipeline reads ci/pipeline.yaml from the repository root. The file defines stages, jobs, and steps using a standard structure:
name: CI/CD Pipeline
on:
push:
branches: [main]
paths: ['src/**', 'ci/**', 'package.json']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm test
- name: Publish Docker Image
run: |
docker build -t myapp:${{ github.sha }} .
docker push myapp:${{ github.sha }}
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
This pipeline.yaml is executed by a CI runner. The fetch-depth: 0 ensures full history is available, so git log --since=HEAD~1 can detect changes accurately.
Builds must complete in under 3 minutes. They use a combination of caching, parallel steps, and incremental compilation.
The build step uses npm ci instead of npm install, which reads package-lock.json exactly and avoids drift. The ci command performs npm install with --prefer-offline, ensuring consistent dependency resolution.
For large monorepos, builds are split by package:
- name: Build only changed packages
run: |
npx lerna run build --since origin/main --since origin/main
npx lerna publish --concurrency 4 --no-private
lerna is used to identify which packages changed since the last merge to main. The --since origin/main flag ensures that only the packages touching files in packages/*/src are rebuilt, not the entire workspace.
The build emits a dist/ directory and a package.json with version set to git describe --tags --long --dirty.
Tests run in parallel across multiple jobs. Each job runs a subset of the test suite.
jobs:
test-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: npm ci
- run: npm run test:unit
- name: Upload test results
uses: actions/upload-artifact@v3
with:
name: test-results
path: test-results/
if: always()
For integration tests, a separate job starts a test database and runs end-to-end tests:
jobs:
test-integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready -U testuser -d testdb
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
- run: |
curl -f http://localhost:3000/health
# Verify API is ready
- run: |
psql -h localhost -U testuser -d testdb -c "SELECT count(*) FROM users;"
- name: Upload test logs
uses: actions/upload-artifact@v3
with:
name: integration-logs
path: logs/
The services section starts a PostgreSQL container as a dependency. The options block configures health checks: the container is considered ready only after pg_isready returns success, and the runner waits up to 5 retries with 10-second intervals.
Deployment is the most critical phase. It uses a canary strategy with blue-green promotion and rollbacks on failure.
The deployment step is defined in ci/deploy.yaml:
- name: Deploy to staging
run: |
kubectl apply -f k8s/staging/
kubectl rollout status deployment/myapp --timeout=300s
kubectl get deployment/myapp -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
env:
KUBECONFIG: ${{ secrets.KUBECONFIG_STAGING }}
This applies all Kubernetes manifests in k8s/staging/, waits for the rollout to complete, and checks that the deployment is ready. If the rollout takes longer than 300 seconds, the job fails and logs the status condition.
For production, a separate job uses kubectl set image to update the image in a deployment:
- name: Deploy to production
run: |
kubectl set image deployment/myapp \
myapp=${{ env.IMAGE_TAG }} \
--namespace=prod \
--record=true
kubectl rollout status deployment/myapp --namespace=prod --timeout=300s
kubectl get deployment/myapp -n prod -o jsonpath='{.status.conditions[?(@.type=="Available")].status}'
env:
IMAGE_TAG: myapp/${{ github.sha }}
KUBECONFIG: ${{ secrets.KUBECONFIG_PROD }}
The --record=true flag records the deployment command in the deployment’s annotations, enabling auditability.
Rollbacks are triggered automatically if the health check fails:
- name: Rollback on failure
run: |
kubectl rollout undo deployment/myapp --namespace=prod --to-revision=1
kubectl get deployment/myapp -n prod -o jsonpath='{.status.conditions[?(@.type=="Available")].status}'
if: ${{ steps.deploy-prod.outcome == 'failure' }}
Each pipeline run emits a pipeline run ID, stored in the CI_PIPELINE_ID environment variable. This ID is attached to every artifact and deployment.
A monitoring job checks the health of the deployed service:
- name: Monitor production
run: |
curl -f http://myapp.prod.example.com/health
curl -f http://myapp.prod.example.com/metrics
curl -f http://myapp.prod.example.com/ready
env:
HEALTH_CHECK_URL: http://myapp.prod.example.com
id: monitor
The job fails if any of the curl commands return a non-200 status. The failure is visible in the CI interface, with links to logs, artifacts, and the deployed manifest.
Additionally, the pipeline sends a summary to a Slack channel:
- name: Send Slack notification
uses: slackapi/slack-app-action@v1
with:
payload: |
{
"text": "Pipeline completed: ${ { github.event.head_commit.message } }",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Pipeline*: ${ { github.workflow } }"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Status*: ${ { steps.deploy-prod.outcome } }"
},
{
"type": "mrkdwn",
"text": "*Duration*: ${ { steps.build.duration } }"
},
{
"type": "mrkdwn",
"text": "*Image*: ${ { env.IMAGE_TAG } }"
}
]
}
]
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
The blocks field uses Slack’s block kit format, allowing rich, structured notifications.
The pipeline silently fails in several ways:
docker build command completes, but docker push fails without error logging. The pipeline logs only docker build output, but push errors are hidden. Solution: always set -e and set -u in shell steps.timeout command and wait until all processes complete.kubectl rollout status command fails, but the job does not run the rollback step. The deployment is stuck in Progressing state. Solution: wrap deployment and health check in a try block.kubectl rollout undo, but the new image is not set. Solution: kubectl set image after undo.--record in kubectl set image: The rollout is recorded, but the --record flag is missing. The pipeline logs show no history of changes. Solution: always include --record=true.A CI/CD pipeline that delivers code in minutes relies on a deep understanding of how each tool behaves, not just what it does. The pipeline is not defined by a product’s UI, but by a set of conventions: Git commit formats, YAML configuration, shell scripts with precise flags, Kubernetes manifests with health checks, and Slack notifications with structured payloads. Each stage is atomic, idempotent, and observable. When it fails, the failure is visible, traceable, and actionable. This design is not a checklist—it is a pattern that can be applied across any platform, from GitHub Actions to GitLab CI to Argo CD, with no loss of fidelity.
This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy