Skip to content

krmaryum/github-actions-kubernetes-masterclass

 
 

Repository files navigation

SkillPulse — GitHub Actions & Kubernetes Masterclass

A small, real application with a real CI/CD pipeline. The app — SkillPulse — lets you track skills you're learning and the hours you put in. The point isn't the app. The point is everything around it: how a single git push becomes a running update on a server in under two minutes, with no human pressing any button.

This repo is the working demo for the TrainWithShubham GitHub Actions & Kubernetes Masterclass.

New here? Two beginner-friendly companion guides:

  • docs/skillpulse-cicd-guide.pdf — chapter one. 29 pages on the GitHub Actions pipeline: DevOps foundations, CI/CD, containers, deploying to a real EC2, plus resume + interview prep.
  • docs/skillpulse-kubernetes-guide.pdf — chapter two. 32 pages on running this app on a local kind cluster: Kubernetes primitives, manifest walkthrough, the dev loop, real failures we hit (arch mismatches, port collisions), interview prep.

Why DevOps matters

For most of software's history, the people who wrote software and the people who ran it were two different teams with two different goals.

  • Developers wanted to ship features.
  • Operations wanted stability.

The fastest way for ops to be stable was to slow developers down. The fastest way for developers to ship was to throw code over the wall. Both teams were right. Both teams were also miserable. And the customer paid the price — releases happened once a quarter, every release was scary, and bugs took weeks to fix.

DevOps is the cultural and technical answer to that: the same team owns the change all the way to production, and tooling makes that safe. It's not a job title. It's a way of working that says small, frequent, automated, and reversible beats big, rare, manual, and irreversible — every time.

When DevOps is working you can tell because:

  • Deploys are boring. Friday afternoon, Monday morning, doesn't matter.
  • Rollbacks are cheap. A bad deploy is a 30-second fix, not an incident.
  • Feedback is fast. A broken commit fails CI in minutes, not "after QA next sprint."
  • Ownership is clear. The person who wrote the code is the person who watches it ship.

You get there by automating the path from a developer's laptop to production. That automation is called a pipeline.


Why CI/CD is the heart of DevOps

CI/CD is two ideas wearing one acronym.

  • Continuous Integration — every change, from every developer, gets built and tested automatically the moment it lands. You catch breakage in minutes, not days. Merge conflicts shrink because nobody's branch lives for two weeks.
  • Continuous Delivery / Deployment — every change that passes CI is automatically packaged and shipped — to staging, or all the way to production. There is no "deploy day." Every commit is a candidate release.

The reason this matters: the cost of fixing a bug grows with the time between writing it and finding it. CI/CD shortens that gap to minutes. The reason it's hard: the only way to make it work is to automate everything. Build, test, package, deploy, verify. No "just run this script on my laptop" steps. If a human has to remember it, it will eventually be forgotten — and then it will fail at 2 a.m.


Why GitHub Actions

A pipeline needs a runner — something that watches your repo, executes your build/test/deploy steps, and reports back. Historically that meant standing up a Jenkins server, paying for CircleCI, or wiring something custom. All of those still work; none of them are the lowest-friction option in 2026.

GitHub Actions wins on three things:

  1. It lives where the code lives. No separate server, no separate auth, no separate UI. Your .github/workflows/*.yml files are part of the repo — they evolve with the code, get reviewed in the same PRs, and survive every clone.
  2. It's free for public repos and generous for private ones. A complete CI/CD pipeline costs zero rupees to start.
  3. The Marketplace is enormous. Need to SSH into a server? appleboy/ssh-action. Need to log in to Docker Hub? docker/login-action. You compose pre-built blocks instead of writing bash from scratch.

The trade-off is GitHub lock-in. For most teams, that's a fair price for the integration.


What this project demonstrates

A real pipeline, end to end, in roughly 50 lines of YAML.

┌─────────────┐     git push        ┌──────────────────┐
│  Developer  ├────────────────────▶│  GitHub Repo     │
└─────────────┘                     └────────┬─────────┘
                                             │ on: push (main)
                                             ▼
                                    ┌──────────────────┐
                                    │  CI Workflow     │
                                    │  - build images  │
                                    │  - tag :sha      │
                                    │  - tag :latest   │
                                    │  - push to Hub   │
                                    └────────┬─────────┘
                                             │ workflow_run: success
                                             ▼
                                    ┌──────────────────┐
                                    │  CD Workflow     │
                                    │  - SSH to EC2    │
                                    │  - git pull      │
                                    │  - compose pull  │
                                    │  - compose up -d │
                                    └────────┬─────────┘
                                             │
                                             ▼
                                    ┌──────────────────┐
                                    │  EC2: live app   │
                                    │  http://<host>   │
                                    └──────────────────┘

CI — .github/workflows/ci.yml

Triggered on every push to main. It does four things:

  1. Checks out the code. A fresh clone in a clean Ubuntu runner — no laptop state to leak.
  2. Builds two Docker images. A Go backend and an Nginx-served frontend. Both are multi-stage so the final images are small.
  3. Tags each image twice. With the commit SHA (:abc1234…) and with :latest. The SHA tag is your rollback handle — you can always pin a deploy to an exact commit. The :latest tag is what production pulls.
  4. Pushes both to Docker Hub. Authenticated with secrets (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN) — never plaintext credentials in the repo.

The non-obvious lesson: CI doesn't just test your code. It produces an artifact. That artifact — the image — is what production runs. If the artifact is built consistently in CI, it's the same in dev, staging, and prod. "Works on my machine" stops being a possibility.

CD — .github/workflows/cd.yml

Triggered automatically when CI completes successfully (workflow_run + a conclusion == 'success' gate). Skipped if CI failed — you cannot deploy a broken build.

It SSHes into an EC2 instance and runs:

if [ ! -d ~/skillpulse ]; then
  git clone <this repo> ~/skillpulse
fi
cd ~/skillpulse
git pull origin main
[ -f .env ] || { echo "ERROR: .env missing"; exit 1; }
docker compose pull
docker compose up -d
docker image prune -f

Every line earns its place:

  • The if [ ! -d ... ] makes the script idempotent — the same script runs whether it's the first deploy or the hundredth.
  • The .env check fails loudly with a useful message instead of letting docker compose produce a cryptic error about missing variables.
  • docker compose pull brings in the image you just built. up -d only recreates containers whose image actually changed — backend and DB don't get bounced if you only edited frontend HTML.
  • docker image prune -f keeps the EC2 disk from filling up with old image layers over weeks of deploys.

Secrets used

Secret What it is
DOCKERHUB_USERNAME Your Docker Hub account name
DOCKERHUB_TOKEN A Docker Hub Personal Access Token with read+write scope
EC2_HOST Public IP or DNS of the deploy target
EC2_USER Linux user on the EC2 (typically ubuntu)
EC2_SSH_KEY Private key contents — paste the entire .pem file as the secret value

Set them at Settings → Secrets and variables → Actions on your fork.


The application itself

A three-tier app — kept tiny on purpose so the pipeline is the star.

Tier Tech What it does
Frontend HTML + CSS + vanilla JS, served by Nginx UI for adding skills and logging hours
Backend Go 1.26 + Gin REST API at /api/...
Database MySQL 8.4 Stores skills and learning logs

Nginx in the frontend image also reverse-proxies /api/ and /health to the backend, so the public surface is a single port (80).

API surface:

GET    /api/skills              list skills + total hours
POST   /api/skills              create skill
GET    /api/skills/:id          one skill + its logs
DELETE /api/skills/:id          delete skill (cascades logs)
POST   /api/skills/:id/log      log a study session
GET    /api/dashboard           summary counters
GET    /health                  DB ping for healthchecks

Run it locally

cp .env.example .env             # fill in DOCKERHUB_USERNAME (anything works for local)
docker compose up -d --build

Open http://localhost. Backend port 8080 is intentionally not exposed — all traffic goes through Nginx, exactly like production.

To tear down:

docker compose down -v           # -v also drops the MySQL volume

Run on Kubernetes (kind)

Same app, same images, same external port — but now every primitive a student would see in production: namespace, deployment, service, statefulset, configmap, secret, pvc.

Prerequisites: Docker Desktop running, plus brew install kind kubectl.

make up                          # creates the kind cluster + applies manifests
# visit http://localhost:8888
make down                        # deletes the cluster (and the MySQL data with it)

What make up actually runs, in order:

docker build -t trainwithshubham/skillpulse-backend:latest  ./backend
docker build -t trainwithshubham/skillpulse-frontend:latest ./frontend
kind create cluster --config k8s/kind-config.yaml --name skillpulse
kind load docker-image trainwithshubham/skillpulse-backend:latest  --name skillpulse
kind load docker-image trainwithshubham/skillpulse-frontend:latest --name skillpulse
kubectl apply -f k8s/00-namespace.yaml \
              -f k8s/10-mysql.yaml \
              -f k8s/20-backend.yaml \
              -f k8s/30-frontend.yaml
kubectl rollout status statefulset/mysql   -n skillpulse --timeout=180s
kubectl rollout status deployment/backend  -n skillpulse --timeout=120s
kubectl rollout status deployment/frontend -n skillpulse --timeout=60s

Notes on this flow:

  • docker build runs on your laptop, producing images for your host's architecture (Apple Silicon → arm64; Intel/Linux → amd64). The cluster never has to deal with multi-arch.
  • kind load docker-image copies each image into the kind node's containerd. imagePullPolicy: IfNotPresent on the Deployments means k8s reuses the loaded image and never tries to pull from Docker Hub.
  • kind-config.yaml lives alongside the manifests for proximity, but it's a kind config — not a Kubernetes resource — so it's fed to kind create cluster, not kubectl apply.

Inner-loop after editing code: make restart rebuilds the images, reloads them into the cluster, and rolls the Deployments.

How traffic flows

The cluster has three nodes: one control-plane and two workers (skillpulse-worker, skillpulse-worker2). Workloads schedule onto the workers — the control-plane is tainted NoSchedule by default, so it stays focused on the API server, scheduler, and controller-manager.

host browser            kind cluster (1 control-plane + 2 workers)
http://localhost:8888
        │
        ▼ (kind extraPortMappings on control-plane: hostPort 8888 → nodePort 30080)
   Service frontend (NodePort 30080)  — reachable on every node, kube-proxy routes
        │
        ▼
   Deployment frontend (nginx + static)  — runs on whichever worker the scheduler picks
        │ proxy_pass http://backend:8080  (same hostname as docker-compose)
        ▼
   Service backend (ClusterIP 8080)
        │
        ▼
   Deployment backend (Go + Gin)
        │ DB_HOST=mysql
        ▼
   Service mysql (Headless 3306)
        │
        ▼
   StatefulSet mysql + 1Gi PVC + ConfigMap-mounted init.sql

Manifest layout

k8s/
  kind-config.yaml      cluster shape: 1 control-plane + 2 workers, host 8888 → node 30080
  00-namespace.yaml     namespace: skillpulse
  10-mysql.yaml         Secret + ConfigMap (init.sql) + headless Service + StatefulSet + 1Gi PVC
  20-backend.yaml       Deployment + ClusterIP Service, env from Secret, /health probes
  30-frontend.yaml      Deployment + NodePort Service (30080), / probes

Useful commands

Command What it does
make status One-screen view of pods, services, endpoints
make logs Tail all three workloads at once
make mysql Open a mysql shell in the StatefulSet pod
make restart Roll backend + frontend (e.g. after pushing a new image)

Smoke test

curl http://localhost:8888/health                 # → {"status":"healthy"}
curl http://localhost:8888/api/dashboard          # → seed-data counters
curl -s http://localhost:8888/ | grep '<title>'   # → HTML title containing "SkillPulse"

Gotchas worth knowing

  • Docker Desktop must be running. docker build, kind, and kubectl all talk to the Docker daemon on your machine.
  • First boot is slow. The local-path provisioner has to materialise the PVC before MySQL starts. Expect 10–30s of Pending on make up's first run.
  • Host port collision. If something else owns 8888 on the host, the cluster comes up but curl localhost:8888 fails. Free the port — or change hostPort in k8s/kind-config.yaml and re-run make down && make up.
  • No Docker Hub round-trip in this chapter. Images are built locally and pushed into the kind node via kind load. Useful when you're iterating on code: make restart rebuilds + reloads + rolls without ever touching Docker Hub. (Production EKS/GKE clusters do pull from a registry — that's the next chapter.)

What's next

This is the kind chapter — same app, real Kubernetes primitives, but limited to one local node and NodePort access. The next chapter graduates the same workload to:

  • An Ingress controller (nginx-ingress) so traffic enters via Ingress rules instead of NodePort.
  • Helm or Kustomize so the manifests stop being copy-pasted between environments.
  • A real cloud cluster (EKS / GKE / AKS) and CD that runs kubectl apply from the pipeline instead of appleboy/ssh-action.

Continuous deployment to the kind cluster

The new CD path doesn't kubectl apply from GitHub Actions — your kind cluster lives on your laptop, GitHub can't reach it. Instead, the pipeline takes the GitOps shape: the repo is the source of truth, your cluster is one git pull && make apply away.

git push to main
    ↓
CI: build images, push trainwithshubham/skillpulse-{backend,frontend}:{latest,<sha>}
    ↓
cd-k8s.yml: sed image: lines in k8s/20-backend.yaml + k8s/30-frontend.yaml
            commit "deploy: pin backend+frontend to <short-sha>" to main as github-actions[bot]
    ↓
(you, locally):
    git pull && make apply
    ↓
kind nodes pull the new :<sha> from Docker Hub → rolling update

How to wire it up on your fork

  1. Fork this repo + clone locally. make up should work after that (see the Run on Kubernetes (kind) section).

  2. Add two secrets to your fork (Settings → Secrets and variables → Actions):

    Secret Value
    DOCKERHUB_USERNAME your Docker Hub account name
    DOCKERHUB_TOKEN a Docker Hub Personal Access Token with Read & Write scope
  3. Set the repo variable DEPLOY_ENABLED = "true" (Settings → Variables → Actions). Until this is true, CI builds without pushing and both CD workflows skip cleanly — the "dry run" state.

  4. Push any code change (not a .md, not under k8s/ or docs/ — those are deliberately ignored by CI). Watch the Actions tab:

    • CI builds + pushes both images to Docker Hub.
    • CD (kind cluster — manifest bump) commits a deploy: pin backend+frontend to <sha> change to main.
  5. Pull and deploy, on the laptop with the kind cluster:

    git pull
    make apply
    kubectl get pods -n skillpulse -o wide

    You'll see new pods with the bumped image rolling out. mysql untouched.

What about the EC2 path?

The previous chapter's cd.yml is still in the repo — it SSHes into an EC2 and runs docker compose up. It's gated on the same DEPLOY_ENABLED variable plus three EC2 secrets (EC2_HOST, EC2_USER, EC2_SSH_KEY). Skip those secrets and cd.yml will fail loudly when DEPLOY_ENABLED=true; that's expected — it's the previous chapter's deploy target, kept around as the masterclass artifact.

Break it on purpose to learn

  • Push a commit that fails to build → both CD workflows are skipped, not failed (the if: success() gate).
  • Rotate the Docker Hub token → next CI fails at the login step. You'll learn what an expired credential looks like in logs.
  • Edit k8s/20-backend.yaml's image tag by hand and push → CI is skipped (paths-ignore), cd-k8s.yml does fire but the manifest is already pinned, so it no-ops and exits 0. That's the loop-protection working.

Project layout

backend/                Go service
  Dockerfile            multi-stage: golang:1.26-alpine → alpine:3.23
  main.go               wires routes, reads PORT env
  database/db.go        connects to MySQL with retry-loop
  handlers/             skills, logs, dashboard endpoints
  models/               request/response structs

frontend/               static UI + Nginx config
  Dockerfile            FROM nginx:alpine, copies html/css/js + nginx.conf
  index.html, css/, js/ vanilla — no build step
  nginx.conf            serves the site, proxies /api/ to backend:8080

mysql/init.sql          schema + seed data, mounted into the MySQL container

docker-compose.yml      three services: db, backend, frontend
.env.example            copy to .env

.github/workflows/
  ci.yml                build + push images on every main push
  cd.yml                SSH + redeploy on CI success

Where this goes next

This is the GitHub Actions half of the masterclass. The pipeline currently deploys to a single EC2 via SSH + docker compose — a fine starting point, and the most common "first real pipeline" in the industry.

The Kubernetes half of the course evolves this same app onto a cluster:

  • Replace docker compose with manifests (Deployment, Service, Ingress).
  • Replace SSH-driven deploys with kubectl apply from CI, then with GitOps (Argo CD / Flux).
  • Add health checks, autoscaling, rolling updates with no downtime, secrets via Kubernetes Secrets or external managers.
  • Run the cluster on EKS / GKE / AKS or local (kind / minikube).

Same app. Same pipeline shape. Different runtime — and a lot more power.


Credits

Built for the TrainWithShubham community. If this repo helped you understand a real CI/CD pipeline end to end, share it forward — that's how the community grows.

About

Hands-on DevOps project showcasing GitHub Actions CI/CD, Docker containerization, Kubernetes deployment on kind, and a three-tier SkillPulse application with automated build and deployment pipelines.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • HTML 44.4%
  • CSS 17.4%
  • JavaScript 16.7%
  • Go 16.4%
  • Makefile 4.1%
  • Dockerfile 1.0%