A complete case study of a production pipeline architecture from scratch: quality gates on pull requests, auto versioning, multi-arch image builds, Trivy scans, staging deploys, automated E2E, a manual approval gate, and zero-downtime production deploys. Closed with a production readiness checklist.

From episode 0 to 19 we assembled the CI/CD pipes one by one: basic workflows, variables, matrix, artifacts, secrets, OIDC, reusable workflows, Docker, deployment, self-hosted runners, code quality, security scans, environment protection, release management, and debugging. In this final episode we combine everything into one complete production architecture — from a developer pressing the merge button to a customer using a new feature, all automatic with layered safeguards.
In this episode we discuss:
Picture the flow like an airplane boarding lane: every gate checks documents before a passenger boards. If one gate is skipped, the risk spreads to all passengers. Our pipeline has five main gates:
Two separate workflows maintain separation of responsibilities: ci.yml for pull requests (fast feedback), cd.yml for main (full release).
Lint, unit test, CodeQL, and audit jobs run in parallel. All must pass before branch protection allows a merge — we never let broken code into main:
name: CI - Pull Request
on:
pull_request:
permissions:
contents: read
security-events: write
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx eslint . --max-warnings 0
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm test
codeql:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ['javascript-typescript']
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3
dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm audit --audit-level=highThese four jobs are the first wall. lint keeps style and small bugs in check, unit-test proves the logic works, codeql looks for static vulnerabilities, and dependency-audit ensures there are no high-level CVEs in dependencies. Additional automatic dependency audit from Dependabot (episode 16) covers gaps outside working hours.
This is the most important workflow. Notice the needs chain — every job waits for the quality of the previous one. The image value is taken from the version job via its outputs, so the entire pipeline uses the same tag:
name: Production Pipeline
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: write
packages: write
security-events: write
jobs:
version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout kode
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Auto semantic versioning
id: release
uses: google-github-actions/release-please-action@v4
with:
release-type: simple
token: ${{ secrets.GITHUB_TOKEN }}
- name: Tentukan tag rilis
id: version
env:
RELEASE_TAG: ${{ steps.release.outputs.tag_name }}
run: |
tag="$RELEASE_TAG"
if [ -z "$tag" ]; then
tag=$(git tag --sort=-v:refname | head -1)
fi
echo "tag=${tag:-v0.0.0}" >> "$GITHUB_OUTPUT"
build-push:
needs: version
runs-on: ubuntu-latest
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push multi-arch image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
scan:
needs: build-push
runs-on: ubuntu-latest
steps:
- name: Scan image dengan Trivy
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
- name: Upload hasil scan ke Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
deploy-staging:
needs: [build-push, scan]
runs-on: ubuntu-latest
environment: staging
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Deploy staging (rolling update)
uses: azure/k8s-deploy@v5
with:
namespace: staging
manifests: k8s/staging.yaml
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
e2e:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependensi
run: npm ci
- name: Jalankan E2E Playwright
run: npx playwright test
env:
BASE_URL: https://staging.example.com
deploy-production:
needs: e2e
runs-on: ubuntu-latest
environment: production
concurrency:
group: production
cancel-in-progress: false
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Deploy zero-downtime ke production
uses: azure/k8s-deploy@v5
with:
namespace: production
manifests: k8s/production.yaml
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
strategy: canary
action: deploy
release:
needs: [version, deploy-production]
runs-on: ubuntu-latest
if: needs.version.outputs.tag != ''
steps:
- name: Buat GitHub Release dengan changelog
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
generate_release_notes: true
notify:
needs: [version, deploy-production, release]
runs-on: ubuntu-latest
if: always()
steps:
- name: Kirim notifikasi Slack
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
VERSION_TAG: ${{ needs.version.outputs.tag }}
run: |
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Rilis ${VERSION_TAG} berhasil dideploy\"}"Important notes from the workflow above:
version job computes the version from Conventional Commits via release-please. The value of the steps.release.outputs.tag_name context is passed through env, not directly into run — the anti-script-injection practice from episode 9.build-push builds the image for two architectures at once with GitHub Actions caching (type=gha), then scan inspects the image with Trivy and writes the SARIF results to the Security tab.environment, so staging and production protection rules apply automatically.The last three mechanisms make this pipeline "production-grade":
BASE_URL, catching regressions that pass unit tests, like a login flow across services.deploy-production job uses the production environment with required reviewers (episode 17). GitHub pauses the job and asks for the Lead Engineer's approval before any step runs — the moment automation hands control to a human.concurrency ensures only one production deployment runs at a time.Warning
If you use a self-hosted runner on a public repository, anyone can run arbitrary code on your runner. A production pipeline like this is only safe on a private repository, or with GitHub-hosted runners.
Before claiming your pipeline is "production-grade", check all the following layers:
| Layer | Tool in the pipeline | Minimum standard |
|---|---|---|
| Code quality | ESLint, unit test | Must pass before merge |
| SAST | CodeQL | Scan every pull request |
| Dependency audit | npm audit, Dependabot | No open high CVEs |
| Versioning | release-please | SemVer from Conventional Commits |
| Image build | Buildx multi-arch | amd64 and arm64 |
| Image security | Trivy | Block if HIGH/CRITICAL |
| Registry | GHCR | Automatic push, package permission |
| Staging deploy | Kubernetes rolling | Automatic after build passes |
| E2E | Playwright | All specs pass |
| Production gate | Production environment | Required reviewers active |
| Production deploy | Zero-downtime/canary | Rollback strategy available |
| Release & communication | GitHub Release, Slack | Automatic notification per release |
If all the layers are present, the pipeline is no longer a manual task collector — it becomes a system that maintains quality, security, and release speed all at once.
In this final episode we assembled the whole series:
Congratulations — you've completed the journey from zero to a full production pipeline. Don't stop here: try applying it to a real project, learn the unexpected failure modes, and keep measuring the time from commit to production. Happy practicing, and see you in the next series!