🚀 Weekend Project: Devops & Deployment
This is the capstone build for the whole module. Over one focused weekend you'll wire a real full-stack app to a pipeline that carries every git push from your laptop all the way to a monitored production deployment — automated builds, tests, container images, a Kubernetes cluster, and a Prometheus/Grafana/Loki observability stack watching it all. You'll build it in five milestones, tick off a concrete checklist as you go, and measure your result against a clear "what good looks like" rubric.
🎯 Learning Objectives
By the end of this project, you will be able to:
- Assemble an end-to-end CI/CD pipeline that builds, tests, scans, and deploys a full-stack app on every push
- Containerize a frontend and backend with production-grade multi-stage Dockerfiles and orchestrate them with Kubernetes manifests
- Instrument a service to expose Prometheus metrics and add liveness/readiness health checks
- Deploy Prometheus, Grafana, and Loki, then build dashboards and alerts that make the system observable
- Evaluate your own pipeline against the four DORA metrics and a completion rubric
Estimated Time: 8–12 hours (a weekend) • Difficulty: Advanced (capstone)
Hands-on: This lesson is the exercise — a milestone-by-milestone build with a checklist and self-assessment rubric.
In This Lesson
What You're Building
Everything in Module 28 has been a piece of one machine: CI/CD, Docker, Kubernetes, and monitoring. This weekend you connect the pieces into a working whole. The goal is a repository where a single push to main triggers a chain that ends with a new version running in a cluster, its metrics flowing into dashboards, and alerts armed to page you if something breaks.
You can start from an app you built earlier in the course or use a small reference app — a frontend (React or Vue), a backend API (Node/Express, Flask/Django, or Laravel), and a database (PostgreSQL, MongoDB, or MySQL). The stack barely matters; the pipeline is the deliverable.
📖 Key Terms
CI/CD: Continuous Integration (merge and test small changes often) plus Continuous Delivery/Deployment (ship those changes automatically).
Manifest: a YAML file that declares a Kubernetes object (a Deployment, Service, Ingress, and so on) in a form the cluster reconciles toward.
Observability: the ability to ask arbitrary questions about a running system from the outside — built from metrics (numbers over time), logs (events), and traces (request paths).
💡 Work in milestones, not marathons
Each milestone below ends in something you can verify on its own. Do not try to write all the YAML first and debug at the end — build one milestone, confirm it works, commit, then move on. That tight build-verify-commit loop is exactly how real delivery teams keep a complex pipeline sane.
Target Architecture
Here is the whole system you're assembling. Read it left to right: your code flows through the pipeline into the cluster, and the monitoring stack observes the cluster from the side.
Milestone 0 — Prep & Ground Rules
Before writing any YAML, get your tools and your repo in order. This half-hour of setup prevents most of the frustration people hit later.
Install the toolbelt
- Docker — build and run images locally
- kubectl — talk to a cluster
- A local cluster —
minikubeorkind(Kubernetes-in-Docker) both work great on a laptop - Helm — installs the monitoring stack in one command
- A GitHub repo and a container registry (Docker Hub or GitHub Container Registry,
ghcr.io)
Confirm the essentials are alive before you start:
docker version # daemon running?
kubectl cluster-info # cluster reachable?
minikube start # or: kind create cluster
helm version # Helm v3.x
Ground rules for the weekend
⚠️ Three rules that will save your weekend
- Never commit secrets. Passwords, tokens, and kubeconfigs live in GitHub Actions secrets and Kubernetes Secret objects — never in the repo. (Note: a Kubernetes Secret is base64-encoded, not encrypted — treat it as "keep out of git", not "safe to share".)
- Commit after every green milestone. A working checkpoint you can return to is worth more than an hour of untangling.
- Pin your versions. Tag images with the Git SHA, not just
latest, so you always know exactly what is running.
Milestone 1 — Containerize the App
Goal: a production image for the frontend and one for the backend, plus a Docker Compose file so the whole app runs locally with one command. Use multi-stage builds so the final images ship only what they need to run — no build tools, no source, no dev dependencies.
Frontend Dockerfile (React/Vite → Nginx)
# frontend/Dockerfile
# Stage 1: build the static bundle
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: serve it with a tiny web server
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
The build stage needs Node and hundreds of megabytes of dependencies; the runtime stage is just Nginx serving static files. Copying only /app/dist across the stage boundary is what keeps the final image small.
Backend Dockerfile (Node/Express)
# backend/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
# Install only production dependencies in the final image
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
# Run as a non-root user for safety
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
✅ Verify Milestone 1
Bring the whole stack up locally and hit it in a browser:
docker compose up --build
# frontend on http://localhost:8080, backend on http://localhost:3000
If the pages load and the API responds, commit. Here is a Compose file to base yours on:
# compose.yaml
services:
frontend:
build: ./frontend
ports: ["8080:80"]
depends_on: [backend]
backend:
build: ./backend
ports: ["3000:3000"]
environment:
NODE_ENV: development
DB_HOST: database
DB_PORT: "5432"
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: appdb
depends_on: [database]
database:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
ports: ["5432:5432"]
volumes:
db-data:
Milestone 2 — Instrument for Observability
Goal: the backend exposes a /metrics endpoint Prometheus can scrape, and a /health endpoint Kubernetes can probe. You are building the hooks now so that Milestone 5 has something to watch.
// backend/src/metrics.js
const express = require('express');
const { Counter, Gauge, Histogram, register, collectDefaultMetrics } = require('prom-client');
// Node process metrics (event loop, heap, GC) for free
collectDefaultMetrics();
const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status'],
});
const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
});
const activeConnections = new Gauge({
name: 'http_active_connections',
help: 'Number of in-flight connections',
});
// Middleware: measure every request
function metricsMiddleware(req, res, next) {
activeConnections.inc();
const stopTimer = httpRequestDuration.startTimer();
res.on('finish', () => {
const labels = {
method: req.method,
route: req.route?.path ?? req.path,
status: res.statusCode,
};
httpRequestsTotal.inc(labels);
stopTimer(labels);
activeConnections.dec();
});
next();
}
module.exports = { metricsMiddleware, register };
// backend/src/server.js
const express = require('express');
const { metricsMiddleware, register } = require('./metrics');
const app = express();
app.use(metricsMiddleware);
// Liveness/readiness probe target
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Prometheus scrape target
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
// ...your real API routes...
app.get('/api/users', async (req, res) => {
res.json([{ id: 1, name: 'Ada' }]);
});
app.listen(3000, () => console.log('API listening on :3000'));
✅ Verify Milestone 2
With the stack running, both endpoints should respond:
curl localhost:3000/health # {"status":"ok"}
curl localhost:3000/metrics # a wall of Prometheus text metrics
Milestone 3 — The CI/CD Pipeline
Goal: a GitHub Actions workflow that on every push runs tests, builds and scans images, and (only on main) deploys to the cluster. The three jobs run in order — test gates build, which gates deploy — so a broken commit never reaches production.
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install & test
run: |
npm ci --prefix frontend
npm ci --prefix backend
npm run lint --prefix backend
npm test --prefix frontend
npm test --prefix backend
build:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:${{ github.sha }}
- name: Build & push frontend
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:${{ github.sha }}
- name: Scan backend image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:${{ github.sha }}
severity: 'CRITICAL,HIGH'
exit-code: '1' # fail the build on a serious CVE
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
- name: Set kube context
uses: azure/k8s-set-context@v4
with:
kubeconfig: ${{ secrets.KUBE_CONFIG }}
- name: Roll out new image
run: |
kubectl -n app set image deployment/backend \
backend=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:${{ github.sha }}
kubectl -n app set image deployment/frontend \
frontend=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:${{ github.sha }}
kubectl -n app rollout status deployment/backend
kubectl -n app rollout status deployment/frontend
💡 Why tag with the Git SHA?
Tagging :${{ github.sha }} instead of :latest means every deploy references an immutable, traceable image. kubectl set image with a new tag is what triggers Kubernetes to do a rolling update; if you reuse :latest, the cluster may not notice anything changed.
✅ Verify Milestone 3
Open a pull request with a trivial change. The test and build jobs should run and the deploy job should be skipped. Then merge to main and watch deploy fire. (Deploy will fail until Milestone 4 exists — that's expected order of operations.)
Milestone 4 — Kubernetes Infrastructure
Goal: manifests that describe the app to the cluster — a namespace, config, secrets, a stateful database, two deployments with health probes and resource limits, services, and an ingress. Keep them in a kubernetes/ folder and apply them once by hand; after that, the pipeline's kubectl set image keeps them current.
Config and secrets
# kubernetes/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: app
---
# kubernetes/config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: app
data:
NODE_ENV: "production"
DB_HOST: "database"
DB_PORT: "5432"
DB_NAME: "appdb"
---
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
namespace: app
type: Opaque
stringData: # stringData lets you write plain text; k8s encodes it
DB_USER: "postgres"
DB_PASSWORD: "change-me-in-real-life"
Backend deployment with health probes
The probes are the payoff from Milestone 2: Kubernetes will restart a pod that fails its liveness check and hold traffic from one that isn't ready.
# kubernetes/backend.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: app
spec:
replicas: 2
selector:
matchLabels: { app: backend }
template:
metadata:
labels: { app: backend }
spec:
containers:
- name: backend
image: ghcr.io/OWNER/REPO-backend:latest # pipeline overrides the tag
ports:
- { name: http, containerPort: 3000 }
envFrom:
- configMapRef: { name: app-config }
- secretRef: { name: app-secrets }
livenessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests: { cpu: "100m", memory: "256Mi" }
limits: { cpu: "500m", memory: "512Mi" }
---
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: app
labels: { app: backend }
spec:
selector: { app: backend }
ports:
- { name: http, port: 80, targetPort: 3000 }
Ingress: one hostname, two paths
# kubernetes/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-ingress
namespace: app
spec:
rules:
- host: app.local
http:
paths:
- path: /api
pathType: Prefix
backend:
service: { name: backend, port: { number: 80 } }
- path: /
pathType: Prefix
backend:
service: { name: frontend, port: { number: 80 } }
✅ Verify Milestone 4
kubectl apply -f kubernetes/
kubectl -n app get pods # all Running and READY 1/1 or 2/2
kubectl -n app get ingress # an address is assigned
# add "127.0.0.1 app.local" to /etc/hosts (with minikube tunnel), then browse app.local
When pods are healthy and the site loads through the ingress, re-run your main pipeline — the deploy job should now go green.
Milestone 5 — Observability Stack
Goal: metrics in Grafana, logs in Grafana, and at least one alert that fires. Helm makes the install a few commands; a ServiceMonitor tells Prometheus to scrape your backend.
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
kubectl create namespace monitoring
# Prometheus + Grafana + Alertmanager in one chart
helm install kps prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false
# Loki for logs (points its output at the Grafana above)
helm install loki grafana/loki-stack \
--namespace monitoring \
--set grafana.enabled=false --set prometheus.enabled=false
Register your backend as a scrape target. The ServiceMonitor's selector must match the label on your backend Service (app: backend), and its port name must match (http).
# kubernetes/service-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: backend
namespace: monitoring
labels: { release: kps } # so kube-prometheus-stack picks it up
spec:
namespaceSelector:
matchNames: [app]
selector:
matchLabels: { app: backend }
endpoints:
- port: http
path: /metrics
interval: 15s
Dashboard queries (PromQL)
Build an application dashboard from the "golden signals" — traffic, errors, and latency:
# Request rate per route
sum(rate(http_requests_total{namespace="app"}[5m])) by (route)
# Error rate (5xx share of traffic), as a percentage
100 * sum(rate(http_requests_total{namespace="app", status=~"5.."}[5m]))
/ sum(rate(http_requests_total{namespace="app"}[5m]))
# 95th-percentile latency
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket{namespace="app"}[5m])) by (le))
One alert that matters
# kubernetes/prometheus-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: app-alerts
namespace: monitoring
labels: { release: kps }
spec:
groups:
- name: app.rules
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{namespace="app", status=~"5.."}[5m]))
/ sum(rate(http_requests_total{namespace="app"}[5m])) > 0.05
for: 5m
labels: { severity: critical }
annotations:
summary: "Backend error rate above 5%"
description: "More than 5% of requests have failed for 5 minutes."
✅ Verify Milestone 5
# Port-forward Grafana and log in (default admin password is in a secret)
kubectl -n monitoring port-forward svc/kps-grafana 3001:80
kubectl -n monitoring get secret kps-grafana \
-o jsonpath='{.data.admin-password}' | base64 -d ; echo
Generate some traffic (a quick load loop against /api), confirm the request-rate panel moves, then hammer a route that 500s and watch HighErrorRate flip to firing. If it does, you have a complete, observable pipeline.
Completion Checklist
Tick every box before you call the project done. Each maps to a milestone above.
📋 Definition of done
- ☐ Frontend and backend each build from a multi-stage Dockerfile; the whole app runs via
docker compose up - ☐ Backend serves
/healthand/metricswith real request metrics - ☐ A push opens a PR that runs test + build + scan; a merge to
mainalso deploys - ☐ Images are tagged by Git SHA and pushed to a registry
- ☐ The image scan fails the build on a CRITICAL/HIGH CVE
- ☐ Cluster runs the app with liveness/readiness probes, resource limits, config in a ConfigMap, and credentials in a Secret
- ☐ Traffic reaches the app through an Ingress
- ☐ Grafana shows rate, errors, and latency for the backend
- ☐ Logs are searchable in Grafana via Loki
- ☐ At least one alert fires under a fault you deliberately inject
- ☐ A
READMEdocuments setup, and the repo contains no secrets
Deliverables to submit
- Repository — app code, Dockerfiles,
kubernetes/manifests, and the Actions workflow. - README — how to run locally, how the pipeline works, how to deploy.
- Architecture diagram — your version of Figure 1 / the flowchart above.
- Screenshots — a green pipeline run,
kubectl get pods, and your Grafana dashboards. - Short reflection — one page: what broke, how you fixed it, what you'd improve.
What Good Looks Like
Anyone can make the checklist go green once. A strong submission shows judgement. Use this rubric to grade yourself honestly — and to know where to push if you have time left.
| Dimension | Just passing | What good looks like |
|---|---|---|
| Automation | Pipeline runs but needs manual steps to deploy | A merge to main deploys with zero human intervention |
| Safety | Tests run; deploy happens regardless | A failing test or a HIGH CVE blocks the deploy; secrets never touch git |
| Reliability | Pods start | Rolling updates cause no downtime; probes restart bad pods automatically |
| Observability | Grafana shows CPU/memory | Dashboards cover the golden signals; an alert fires before you'd notice by hand |
| Reproducibility | Works on your machine | A teammate clones the repo and gets it running from the README alone |
💡 Measure yourself with the DORA metrics
Industry research measures delivery performance with four numbers. Your weekend pipeline should move all four in the right direction:
- Deployment frequency — how often you ship (higher is better)
- Lead time for changes — commit to production (lower is better)
- Change failure rate — share of deploys that break (lower is better)
- Time to restore — how fast you recover from a bad deploy (lower is better)
🚀 Stretch goals (if you finish early)
- Add a staging environment and promote from staging to production.
- Swap the rolling update for a blue-green or canary release.
- Add distributed tracing (OpenTelemetry) to follow a request across services.
- Manage the cluster itself with Terraform and package your manifests as a Helm chart.
Summary & Quiz
🎉 Key Takeaways
- A complete pipeline connects five milestones: containerize → instrument → CI/CD → Kubernetes → observability.
- Build and verify one milestone at a time; commit at every green checkpoint.
- The pipeline's job is safety plus speed — automated tests and scans gate an automated deploy.
- Instrumentation you add early (
/health,/metrics) is what makes the system observable later. - Grade yourself on automation, safety, reliability, observability, and reproducibility — and track the four DORA metrics.
🎯 Quick Quiz
Question 1: Why does the backend Dockerfile use a multi-stage build?
Question 2: In the CI/CD workflow, what stops a commit that breaks the tests from ever reaching production?
Question 3: Which set of signals should the application dashboard prioritize?
📚 Further Reading
- GitHub Actions Documentation
- Kubernetes Documentation
- Prometheus Documentation
- Grafana Documentation
- Google SRE Book — Monitoring & the Golden Signals
- DORA — DevOps research & the four key metrics
🚀 What's Next?
This is the final lesson of Module 28 and the capstone of the DevOps track. From here, head back to the course home to review the modules, revisit anything that felt shaky, or start applying this pipeline to your own portfolio projects.
🎉 You shipped it!
You've taken an app from a local git push all the way to a monitored, self-healing production deployment — the exact workflow modern teams run every day. That's a genuine full-stack DevOps skill set.