
Built a production-ready Kubernetes CI/CD pipeline from scratch using industry best practices. This comprehensive project demonstrates the evolution from rapid prototyping to enterprise-grade infrastructure that is scalable, secure, and maintainable for multi-application deployments.
Unlike my previous two projects which were rapid prototypes focused on "build fast, break things, learn", this project follows a gradual, iterative approach that simulates professional working environments, focusing on systematic improvements and best practices at each stage.
End-to-end CI/CD flow from code commit to production deployment

GitHub Actions CI workflow with validation, testing, and deployment stages
The CI workflow automatically detects changed files and executes appropriate workflows based on the type of changes. This app-specific workflow (dev-ci.yml) orchestrates calls to reusable workflows, ensuring efficient resource usage and fast feedback loops.
name: Dev CI (App + Manifests + GitOps Bump)
on:
push:
branches: [ "dev", "stage", "prod" ]
paths:
- "src/**"
- "public/**"
- "package.json"
- "Dockerfile"
- "manifests/**"
- "policy/**"
pull_request:
branches: [ "stage", "prod" ]
paths:
- "manifests/**"
- "policy/**"
permissions:
contents: write
packages: write
security-events: write
jobs:
# =============================================
# JOB 0: DETECT CHANGED FILES
# =============================================
changes:
runs-on: ubuntu-latest
outputs:
app: ${{ steps.filter.outputs.app }}
manifests: ${{ steps.filter.outputs.manifests }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
app:
- 'src/**'
- 'Dockerfile'
- 'package.json'
manifests:
- 'manifests/**'
- 'policy/**'
# =============================================
# JOB 1: RESOLVE TARGET BRANCH
# =============================================
resolve-branch:
runs-on: ubuntu-latest
outputs:
target_branch: ${{ steps.resolve.outputs.target_branch }}
steps:
- id: resolve
run: |
TARGET_BRANCH="${{ github.event.pull_request.base.ref || github.ref_name }}"
echo "target_branch=$TARGET_BRANCH" >> $GITHUB_OUTPUT
# =============================================
# JOB 2: APP CI (only dev)
# =============================================
app-ci:
needs: [changes, resolve-branch]
if: ${{ needs.changes.outputs.app == 'true' &&
needs.resolve-branch.outputs.target_branch == 'dev' }}
uses: nishanau/ci-cd-templates/.github/workflows/ci-app.yml@main
with:
image_name: nishans0/next-portfolio
context: .
dockerfile: ./Dockerfile
push_image: true
run_tests: true
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
# =============================================
# JOB 3: MANIFESTS CI
# =============================================
manifests-ci:
needs: [changes, resolve-branch]
if: ${{ needs.changes.outputs.manifests == 'true' }}
uses: nishanau/ci-cd-templates/.github/workflows/ci-manifests.yml@main
with:
overlay_path: manifests/overlays/${{ needs.resolve-branch.outputs.target_branch }}
policies_path: policy
kubeconform_flags: "--strict --ignore-missing-schemas"
secrets: inherit
# =============================================
# JOB 4: RESOLVE IMAGE TAG
# =============================================
resolve-tag:
runs-on: ubuntu-latest
needs: [app-ci, manifests-ci, resolve-branch]
if: ${{ always() && (
(needs.resolve-branch.outputs.target_branch == 'dev' &&
needs.app-ci.result == 'success') ||
(needs.resolve-branch.outputs.target_branch != 'dev' &&
(needs.app-ci.result == 'success' || needs.app-ci.result == 'skipped') &&
(needs.manifests-ci.result == 'success' || needs.manifests-ci.result == 'skipped'))
) }}
outputs:
tag: ${{ steps.resolve_tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- id: resolve_tag
run: |
TARGET_BRANCH="${{ needs.resolve-branch.outputs.target_branch }}"
if [[ "$TARGET_BRANCH" == "dev" ]]; then
TAG="sha-${{ github.sha }}"
elif [[ "$TARGET_BRANCH" == "stage" ]]; then
TAG=$(yq e '.images[] | select(.name=="docker.io/nishans0/next-portfolio") | .newTag' \
manifests/overlays/dev/kustomization.yaml)
elif [[ "$TARGET_BRANCH" == "prod" ]]; then
TAG=$(yq e '.images[] | select(.name=="docker.io/nishans0/next-portfolio") | .newTag' \
manifests/overlays/stage/kustomization.yaml)
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
# =============================================
# JOB 5: GITOPS BUMP
# =============================================
bump-gitops:
needs: [app-ci, manifests-ci, resolve-tag, resolve-branch]
if: ${{ always() && github.event_name == 'push' && (
(needs.resolve-branch.outputs.target_branch == 'dev' &&
needs.app-ci.result == 'success') ||
(needs.resolve-branch.outputs.target_branch != 'dev' &&
(needs.manifests-ci.result == 'success' || needs.manifests-ci.result == 'skipped'))
) }}
uses: nishanau/ci-cd-templates/.github/workflows/ci-gitops-bump.yml@main
with:
gitops_repo: nishanau/NextJSPortfolioSite
gitops_path: manifests/overlays/${{ needs.resolve-branch.outputs.target_branch }}/kustomization.yaml
image_name: docker.io/nishans0/next-portfolio
image_tag: ${{ needs.resolve-tag.outputs.tag }}
secrets:
gitops_pat: ${{ secrets.GITOPS_PAT }}First step analyzes the commit to determine which workflows need to run:
src/**, Dockerfile, package.jsonmanifests/**, policy/**Triggered when app code changes, executes comprehensive build pipeline:
sha-{commit})dev branchTriggered when manifest or policy files change:
dev, stage, prod)Conditionally updates manifest image tags based on environment and results:
dev branchsha-{commit-hash}dev or direct pushdev overlaystagestage overlayThe workflow implements intelligent logic to determine when tag updates should occur. This prevents unnecessary deployments and ensures environment integrity:
# Development ā Staging ā Production
1. Dev: Build new image
- Tag: sha-abc123
- Push to Docker Hub
- Update: manifests/overlays/dev/kustomization.yaml
2. Stage: Promote verified build
- Read tag from: manifests/overlays/dev/kustomization.yaml
- Copy tag: sha-abc123
- Update: manifests/overlays/stage/kustomization.yaml
3. Prod: Deploy battle-tested image
- Read tag from: manifests/overlays/stage/kustomization.yaml
- Copy tag: sha-abc123
- Update: manifests/overlays/prod/kustomization.yaml
⨠Same image (sha-abc123) deployed across all environments
⨠Tested in dev, validated in stage, confident in prodBuilt from scratch following Kubernetes best practices:
Declarative continuous delivery implementation:
Kustomize-based configuration management:
manifests/
āāā base/ # Base resources
ā āāā deployment.yaml
ā āāā service.yaml
ā āāā pdb.yaml
ā āāā sa.yaml
āāā overlays/
āāā dev/ # Development environment
āāā stage/ # Staging environment
āāā prod/ # Production environmentModular GitHub Actions templates for consistency:
Comprehensive security measures at every layer:
Pre-deployment checks ensure quality:
# YAML Lint
yamllint manifests/
# Schema Validation
kustomize build overlays/dev | kubeconform --strict
# Policy Testing
kustomize build overlays/dev | conftest test -
# Best Practices Check
kustomize build overlays/dev | kube-score score -Secure public access without port forwarding: