Skip to main content

🚀 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.

flowchart LR subgraph Dev["Your machine"] A[Local code] --> B[git push to main] end subgraph CI["CI/CD — GitHub Actions"] B --> C[Build & test] C --> D[Scan images] D --> E[Push to registry] E --> F[kubectl apply] end subgraph Cluster["Kubernetes cluster"] F --> G[Frontend pods] F --> H[Backend pods] H --> I[(Database)] J[Ingress] --> G J --> H end subgraph Obs["Observability"] H --> K[Prometheus] G --> L[Loki] H --> L K --> M[Grafana] L --> M M --> N[Alerts] end
The five milestones of the weekend build Five stacked milestones — containerize, instrument, CI/CD, Kubernetes, and observability — each feeding the next toward a monitored production deployment. M1 Containerize M2 Instrument M3 CI/CD M4 Kubernetes M5 Observability One weekend, five milestones Each milestone is independently verifiable — build, confirm, commit, repeat.
Figure 1 — The five milestones. Blue = local app work, amber = automation and infrastructure, green = observability. Finish and verify each before starting the next.

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 clusterminikube or kind (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.

flowchart LR A[push / PR] --> B[test job] B -->|green| C[build + scan job] C -->|images pushed| D{on main?} D -->|yes| E[deploy job] D -->|no| F[stop — PR check only]
# .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 /health and /metrics with real request metrics
  • ☐ A push opens a PR that runs test + build + scan; a merge to main also 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 README documents setup, and the repo contains no secrets

Deliverables to submit

  1. Repository — app code, Dockerfiles, kubernetes/ manifests, and the Actions workflow.
  2. README — how to run locally, how the pipeline works, how to deploy.
  3. Architecture diagram — your version of Figure 1 / the flowchart above.
  4. Screenshots — a green pipeline run, kubectl get pods, and your Grafana dashboards.
  5. 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

🚀 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.