How I Deployed My Portfolio: A Production-Grade DevOps Pipeline with K3s, GitOps & Full Observability
A deep-dive into the complete CI/CD pipeline powering deeppatel.site — from local Next.js code to a Kubernetes cluster on AWS EC2, secured by Trivy, orchestrated by Argo CD, and monitored by Prometheus & Grafana.
How I Deployed My Portfolio: A Production-Grade DevOps Pipeline with K3s, GitOps & Full Observability
Most portfolio sites are a Vercel deploy away. Mine isn't — and that's entirely by design.
This post is a complete, honest breakdown of the infrastructure powering deeppatel.site: a production-grade, self-managed Kubernetes cluster on AWS EC2, wired up with GitOps delivery, container security scanning, TLS automation, and a real-time Slack alerting system that notifies me of every deployment, health change, and infrastructure anomaly.
If you want to understand what a real-world DevOps pipeline looks like — not a simplified tutorial version — this is it.
Architecture Overview
The system is divided into four distinct stages, each with a clear purpose and a hard boundary:
┌─────────────────────────────────────────────────────────────┐
│ STAGE 1: LOCAL CODE & SOURCE CONTROL │
│ Next.js → Docker → GitHub │
└─────────────────────────┬───────────────────────────────────┘
│ git push origin main
▼
┌─────────────────────────────────────────────────────────────┐
│ STAGE 2: CI / SEC-OPS │
│ GitHub Actions → Trivy → GHCR → Slack Alert A │
└─────────────────────────┬───────────────────────────────────┘
│ Argo CD detects new image tag
▼
┌─────────────────────────────────────────────────────────────┐
│ STAGE 3: CLOUD INFRASTRUCTURE & GITOPS (CD) │
│ AWS EC2 → K3s → Argo CD → Kubernetes Secrets → Slack B │
└─────────────────────────┬───────────────────────────────────┘
│ User hits deeppatel.site
▼
┌─────────────────────────────────────────────────────────────┐
│ STAGE 4: TRAFFIC ROUTING & OBSERVABILITY │
│ DNS → cert-manager → Prometheus → Grafana → Slack C │
└─────────────────────────────────────────────────────────────┘
Let's go through each stage in full detail.
Stage 1: Local Code & Source Control
The Application: Next.js with Standalone Output
The portfolio is built with Next.js, configured to use the output: 'standalone' build mode. This is a critical choice: standalone mode bundles only the necessary server files and dependencies, producing a lean, self-contained Node.js server — ideal for Docker containers.
// next.config.js
module.exports = {
output: 'standalone',
}
Without standalone mode, you'd need to copy the entire node_modules folder into your Docker image. With it, Next.js does the tree-shaking for you at build time.
The Container: Multi-Stage Dockerfile
Docker multi-stage builds are the standard for production Next.js images. Here's the pattern used:
# ---- Stage 1: Dependencies ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# ---- Stage 2: Build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ---- Stage 3: Runner (final image) ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy only what standalone needs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Why multi-stage?
| Single-Stage | Multi-Stage |
|---|---|
| Carries build tools into final image | Final image only has runtime files |
| Image size: ~800MB+ | Image size: ~120MB |
| Larger attack surface | Minimal attack surface |
| Slower pulls & deploys | Fast Kubernetes pod startup |
The final image carries no build tools, no devDependencies, no source code — just the compiled output and a Node runtime.
The Repository: GitHub + Kubernetes Manifests
Two things live in GitHub:
- Application source code — the Next.js app
- Kubernetes manifests — YAML files describing the desired cluster state
This separation of concerns (app code vs. infra declarations) is the foundation of GitOps. The manifests folder typically looks like:
k8s/
├── deployment.yaml # Pod spec, image tag, replicas
├── service.yaml # Internal cluster networking
├── ingress.yaml # External routing rules
└── kustomization.yaml # Optional: Kustomize overlay config
The image tag in deployment.yaml is the critical link between CI and CD:
# k8s/deployment.yaml
spec:
containers:
- name: portfolio
image: ghcr.io/deeppatel/portfolio:sha-a3f92c1 # ← CI updates this
When CI pushes a new image, it also commits an updated image tag to this file. Argo CD watches this file. That's the trigger.
Stage 2: Continuous Integration (CI) & Sec-Ops
git push
│
▼
GitHub Actions Workflow
│
├── Checkout code
├── Set up Docker Buildx
├── Log in to GHCR (using GitHub Secrets)
├── Build Docker image
├── [SECURITY GATE] Trivy scan
│ ├── PASS → push image to GHCR, update manifest tag
│ └── FAIL → block push, send Slack alert (Alert A)
└── Send Slack notification (Alert A) on success
GitHub Actions: The Pipeline Runner
Every git push to main triggers a GitHub Actions workflow. The workflow YAML lives in .github/workflows/deploy.yml:
name: Build, Scan & Deploy
on:
push:
branches: [main]
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image (load locally for scanning)
uses: docker/build-push-action@v5
with:
context: .
load: true
tags: ghcr.io/deeppatel/portfolio:${{ github.sha }}
- name: Security scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/deeppatel/portfolio:${{ github.sha }}
format: table
exit-code: '1' # ← Fails the pipeline on HIGH/CRITICAL CVEs
severity: 'HIGH,CRITICAL'
- name: Push image to GHCR (only if scan passed)
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/deeppatel/portfolio:${{ github.sha }}
- name: Update Kubernetes manifest with new image tag
run: |
sed -i "s|image: ghcr.io/deeppatel/portfolio:.*|image: ghcr.io/deeppatel/portfolio:${{ github.sha }}|" k8s/deployment.yaml
git config user.email "ci@github.com"
git config user.name "GitHub Actions"
git add k8s/deployment.yaml
git commit -m "ci: update image to ${{ github.sha }}"
git push
- name: Notify Slack
uses: slackapi/slack-github-action@v1
with:
payload: '{"text":"✅ CI passed. Image ${{ github.sha }} pushed to GHCR."}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
GitHub Secrets: Keeping Credentials Out of Code
Two secrets are stored in the repository settings, never in code:
| Secret | Purpose |
|---|---|
GITHUB_TOKEN | Auto-provided by Actions; authenticates pushes to GHCR |
SLACK_WEBHOOK_URL | Incoming webhook URL for Slack notifications |
Rule: Nothing sensitive ever touches the repository. Secrets are injected at runtime by the Actions runner.
The Security Gate: Trivy
Trivy is an open-source vulnerability scanner by Aqua Security. It scans the Docker image against CVE databases (NVD, GitHub Advisory, etc.) before it ever leaves the CI environment.
Docker Image Built
│
▼
Trivy Scans:
├── OS packages (Alpine apk packages)
├── Node.js dependencies (package-lock.json)
├── Language runtime (Node 20)
└── Misconfigurations
Result:
├── No HIGH/CRITICAL → exit code 0 → pipeline continues
└── Found HIGH/CRITICAL → exit code 1 → pipeline FAILS
→ Slack alert sent
→ Image NOT pushed
This is a hard gate. A vulnerable image physically cannot reach the cluster. The pipeline stops, Slack pings you, and you fix the CVE before proceeding.
Image Storage: GitHub Container Registry (GHCR)
GHCR stores Docker images alongside your GitHub repository. Access is controlled by GitHub tokens and repository permissions — no separate registry service to manage.
Images are tagged with the Git commit SHA:
ghcr.io/deeppatel/portfolio:sha-a3f92c1
ghcr.io/deeppatel/portfolio:sha-b7d21e4
SHA-based tags are immutable and traceable. You can always look at a running pod's image tag, find that exact commit in GitHub, and know precisely what code is deployed.
Slack Alert Point A
After the CI run (pass or fail), Slack receives a message:
- On Trivy failure:
🚨 Security scan FAILED for commit abc123. HIGH CVE found in node:20-alpine. Deployment blocked. - On CI success:
✅ CI passed. Image sha-a3f92c1 pushed to GHCR. Argo CD sync pending.
Stage 3: Cloud Infrastructure & GitOps (CD)
GHCR (new image tag committed to Git)
│
▼
Argo CD polls Git every 3 minutes
(or webhook triggers immediately)
│
▼
Detects drift between Git state & cluster state
│
▼
Syncs cluster: applies updated Deployment manifest
│
▼
Kubernetes rolling update:
├── Starts new pod with new image
├── Waits for health check to pass
├── Terminates old pod
└── Zero-downtime deployment complete
│
▼
Slack Alert B: deployment state update
The Cloud Server: AWS EC2 (t3.small)
The entire cluster runs on a single t3.small EC2 instance:
| Spec | Value |
|---|---|
| Instance type | t3.small |
| vCPUs | 2 |
| RAM | 2 GB |
| Storage | 20 GB gp3 EBS |
| OS | Ubuntu 22.04 LTS |
| Public IP | AWS Elastic IP (static) |
A t3.small is the smallest instance that comfortably runs K3s + Argo CD + Prometheus + Grafana simultaneously. The Elastic IP ensures the public IP never changes between instance stops/starts, so DNS stays stable.
Network Firewall: AWS Security Groups
Security Groups are AWS's stateful packet filter — essentially a firewall attached to the EC2 instance. Only four ports are open:
| Port | Protocol | Purpose |
|---|---|---|
| 22 | TCP | SSH access (restrict to your IP in production) |
| 80 | TCP | HTTP (redirected to HTTPS by ingress) |
| 443 | TCP | HTTPS (production traffic) |
| 6443 | TCP | Kubernetes API server (for kubectl access) |
Everything else is dropped. The cluster's internal pod-to-pod networking happens on private VXLAN tunnels that never touch the security group rules.
The Orchestrator: K3s
K3s is a CNCF-certified Kubernetes distribution from Rancher, optimized for edge and resource-constrained environments. It's the full Kubernetes API in a single ~60MB binary.
K3s vs full K8s for a single-node setup:
| K3s | kubeadm (full K8s) | |
|---|---|---|
| Binary size | ~60 MB | Multiple components, ~500MB+ |
| RAM at idle | ~500MB | ~1.5GB+ |
| Install time | 30 seconds | 10-20 minutes |
| etcd | SQLite (default) | etcd |
| Built-in ingress | Traefik | None (install separately) |
| Production-ready | ✅ | ✅ |
K3s installs with a single command:
curl -sfL https://get.k3s.io | sh -
The cluster is immediately ready for workloads. K3s bundles Traefik as the default ingress controller, which handles routing external HTTP/S traffic to internal services.
The GitOps Agent: Argo CD
Argo CD is the CD engine. It runs inside the cluster and continuously reconciles the desired state (Git manifests) with the actual state (running cluster).
Git Repository (desired state)
└── k8s/deployment.yaml
└── image: ghcr.io/deeppatel/portfolio:sha-NEW
Kubernetes Cluster (actual state)
└── Running pod
└── image: ghcr.io/deeppatel/portfolio:sha-OLD
Argo CD detects DRIFT → triggers sync → updates cluster to match Git
Why GitOps matters:
- Every deployment is a Git commit. Full audit trail.
- You can roll back any change with
git revert. - The cluster is self-healing: if someone manually changes a pod, Argo CD reverts it to match Git.
- No credentials on developer machines for cluster access.
Argo CD provides a web UI at https://<your-ip>:8080 where you can see the live sync status of every application:
Application: portfolio
Sync Status: ✅ Synced
Health: 💚 Healthy
Image: ghcr.io/deeppatel/portfolio:sha-a3f92c1
Last Sync: 2 minutes ago
Runtime Auth: Kubernetes Secrets
Environment variables like API keys, database URLs, or any runtime config are stored as Kubernetes Secrets — not in Git, not in the Docker image.
kubectl create secret generic portfolio-secrets \
--from-literal=NEXT_PUBLIC_EMAIL_KEY=resend_... \
--from-literal=ANALYTICS_ID=G-XXXXXXX
These are injected as environment variables into pods at runtime:
# k8s/deployment.yaml
envFrom:
- secretRef:
name: portfolio-secrets
The application code reads them as normal process.env variables. The secret values never touch Git or the Docker image.
Slack Alert Point B: Argo CD Notifications
Argo CD's notification controller watches application state changes and fires webhook payloads to Slack:
| State Change | Slack Message |
|---|---|
Syncing | 🔄 Portfolio deployment syncing — new image sha-a3f92c1 |
Healthy | ✅ Deployment healthy. Portfolio is live. |
Degraded | 🚨 Deployment DEGRADED. Pod crash-looping. Immediate attention required. |
These notifications mean you know about a failed deployment within seconds, before any user reports it.
Stage 4: Traffic Routing & Observability
User types deeppatel.site
│
▼
DNS (A Record → Elastic IP)
│
▼
AWS Security Group (port 443 open)
│
▼
K3s Traefik Ingress Controller
│
├── TLS termination (cert-manager + Let's Encrypt cert)
└── Routes to portfolio Service → Pod
Meanwhile, continuously:
Prometheus scrapes metrics every 15s
│
▼
Alertmanager evaluates rules
│
└── Threshold breached → Slack Alert C
→ Grafana Contact Point → Slack Alert C
DNS Routing
The domain deeppatel.site is registered at a third-party registrar (e.g., Cloudflare, Namecheap). A single A record points the domain to the AWS Elastic IP:
deeppatel.site. A 3.xx.xx.xx (Elastic IP)
www.deeppatel.site A 3.xx.xx.xx (Elastic IP)
Because the IP is elastic (static), it doesn't change when the EC2 instance reboots. Without an elastic IP, every restart would require a DNS update.
SSL/TLS: cert-manager + Let's Encrypt
cert-manager is a Kubernetes operator that automates TLS certificate lifecycle. It integrates with Let's Encrypt (a free CA) to issue and renew certificates automatically.
The process:
cert-manager creates a CertificateRequest
│
▼
ACME HTTP-01 Challenge:
Let's Encrypt says: "prove you own deeppatel.site"
│
▼
cert-manager creates a temporary pod at:
http://deeppatel.site/.well-known/acme-challenge/<token>
│
▼
Let's Encrypt validates the token
│
▼
Issues 90-day TLS certificate
│
▼
cert-manager stores it as a Kubernetes Secret
cert-manager renews automatically at 60 days
This is declared in a Certificate resource:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: portfolio-tls
spec:
secretName: portfolio-tls-secret
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- deeppatel.site
- www.deeppatel.site
Zero manual certificate management. Zero cost. Zero downtime on renewal.
Helm: Cluster Infrastructure Manager
Helm is the package manager for Kubernetes. Rather than writing hundreds of lines of raw YAML to install Prometheus, Grafana, cert-manager, or Argo CD, Helm charts bundle everything:
# Install Prometheus + Alertmanager + Grafana in one command
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--values monitoring-values.yaml
# Install cert-manager
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--set installCRDs=true
Helm also manages upgrades:
helm upgrade monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--values monitoring-values.yaml
Metrics: Prometheus + Alertmanager
Prometheus is the metrics collection engine. It scrapes endpoints every 15 seconds and stores time-series data locally.
What it scrapes:
- Node Exporter — EC2 host metrics (CPU, RAM, disk, network)
- kube-state-metrics — Kubernetes object metrics (pod restarts, deployment health)
- Next.js app — Custom
/metricsendpoint (HTTP request rates, error rates, latency)
Alertmanager processes firing alerts from Prometheus. Alert rules are defined in YAML:
groups:
- name: portfolio.rules
rules:
- alert: SiteDown
expr: up{job="portfolio"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Portfolio is down"
description: "deeppatel.site has been unreachable for 1 minute."
- alert: HighDiskUsage
expr: (node_filesystem_size_bytes - node_filesystem_free_bytes) / node_filesystem_size_bytes > 0.90
for: 5m
labels:
severity: warning
annotations:
summary: "EC2 disk usage above 90%"
- alert: HighMemoryUsage
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
for: 5m
labels:
severity: warning
summary: "EC2 RAM usage above 90%"
When a rule fires, Alertmanager routes the alert to the configured receiver — in this case, a Slack webhook.
Grafana: Visual Dashboards
Grafana connects to Prometheus as a data source and renders live dashboards. The standard kube-prometheus-stack Helm chart comes pre-bundled with dashboards for:
- Node health — CPU %, RAM %, disk I/O, network throughput
- Kubernetes cluster — Pod count, restart count, resource requests vs. limits
- Application — HTTP request rate, error rate (4xx/5xx), P99 latency
Grafana also has contact points that can independently trigger Slack notifications when panels enter alerting state — a second layer of alerting independent of Alertmanager.
Slack Alert Point C
The final alert layer covers infrastructure health:
| Trigger | Source | Slack Message |
|---|---|---|
up{job="portfolio"} == 0 | Alertmanager | 🚨 CRITICAL: deeppatel.site is DOWN |
| Disk usage > 90% | Alertmanager | ⚠️ WARNING: EC2 disk at 92%. Clean up needed. |
| RAM usage > 90% | Alertmanager | ⚠️ WARNING: EC2 RAM at 91%. Pods may be OOM-killed. |
| HTTP 5xx spike | Grafana Contact Point | ⚠️ 5xx error rate elevated on portfolio |
The Three-Layer Slack Alerting System
One of the most useful aspects of this architecture is that three independent systems send Slack notifications at different stages:
CI/CD Alert Layer (Alert A)
━━━━━━━━━━━━━━━━━━━━━━━━━━
Source: GitHub Actions step
Trigger: Trivy scan result (pass or fail)
Signal: "Is my code safe to deploy?"
GitOps Alert Layer (Alert B)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Source: Argo CD notifications controller
Trigger: Deployment state change
Signal: "Did my deployment succeed?"
Infra Alert Layer (Alert C)
━━━━━━━━━━━━━━━━━━━━━━━━━━━
Source: Prometheus Alertmanager + Grafana
Trigger: Metric threshold breach
Signal: "Is my site healthy right now?"
Each layer has a completely different failure mode. Alert A fires before anything reaches the cluster. Alert B fires when the cluster state changes. Alert C fires based on live system metrics regardless of deploys. Together they cover the entire delivery lifecycle.
End-to-End Flow Summary
Here's what happens when you push code:
1. git push origin main
└── Triggers GitHub Actions workflow
2. Actions: Build Docker image (multi-stage, standalone Next.js)
3. Actions: Trivy scans image
├── HIGH/CRITICAL CVE found?
│ └── Pipeline FAILS → Slack Alert A (failure) → STOP
└── Clean? → Continue
4. Actions: Push image to GHCR (tagged with commit SHA)
5. Actions: Commit updated image tag to k8s/deployment.yaml in Git
6. Slack Alert A: "✅ CI passed. Deployment pending."
7. Argo CD detects Git drift (new image tag in manifest)
8. Argo CD syncs cluster: rolling update to new pod
├── New pod starts → health check passes → old pod terminated
└── Zero-downtime deployment
9. Slack Alert B: "✅ Deployment healthy."
10. Prometheus scrapes /metrics every 15s
└── All green? → No alert
└── Threshold breach? → Alertmanager → Slack Alert C
Total time from git push to live update: ~3-5 minutes.
Key Lessons from Building This
1. Standalone output is essential for Next.js in containers.
The difference between a 900MB image and a 120MB image is output: 'standalone' in next.config.js. Always configure this before writing the Dockerfile.
2. Security scanning must be a hard gate, not advisory.
Setting exit-code: '1' in Trivy makes the pipeline fail on findings. Many teams run scanners in "report only" mode — which means findings are ignored under deadline pressure. Hard gates prevent this.
3. SHA-based image tags over latest.
latest is a mutable tag. sha-a3f92c1 is immutable. With latest, you can never be certain what's actually running in your cluster. With SHA tags, you always can.
4. GitOps makes rollback trivial.
Rolling back a broken deployment is git revert <commit> && git push. Argo CD detects the revert and re-syncs the cluster to the previous state. No manual kubectl commands, no hotfixes, no guessing.
5. cert-manager eliminates an entire category of operational burden. Before cert-manager, TLS certificates were a recurring manual task with a 90-day expiry deadline. With cert-manager, you configure it once and forget it for the lifetime of the cluster.
6. K3s is genuinely production-grade. It's easy to assume K3s is a "toy" Kubernetes for local dev. It isn't. It's CNCF-certified, battle-tested at scale, and perfectly suited for single-node and small cluster production workloads. For a portfolio site, it's more than sufficient — and the operational overhead is significantly lower than running full K8s.
Tools & Versions Reference
| Tool | Role | Version Used |
|---|---|---|
| Next.js | Frontend framework | 14.x |
| Docker | Containerization | 24.x |
| GitHub Actions | CI pipeline | N/A (SaaS) |
| Trivy | Container security scanner | 0.50.x |
| GHCR | Container image registry | N/A (SaaS) |
| AWS EC2 | Cloud compute | t3.small |
| K3s | Kubernetes distribution | v1.29.x |
| Argo CD | GitOps CD agent | v2.10.x |
| Helm | Kubernetes package manager | v3.x |
| cert-manager | TLS automation | v1.14.x |
| Prometheus | Metrics collection | v2.50.x |
| Alertmanager | Alert routing | v0.27.x |
| Grafana | Metrics dashboards | v10.x |
This architecture is genuinely overkill for a portfolio site — and that's the entire point. Every component here is a concept used in production at scale: GitOps, container scanning, zero-downtime deployments, metrics-based alerting. Building a "real" pipeline around a simple app is the fastest way to internalize how these systems actually work.
If you have questions about any specific component or want to replicate this setup, the configuration files are available in the repository.