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 localkindcluster: Kubernetes primitives, manifest walkthrough, the dev loop, real failures we hit (arch mismatches, port collisions), interview prep.
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.
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.
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:
- It lives where the code lives. No separate server, no separate auth, no separate UI. Your
.github/workflows/*.ymlfiles are part of the repo — they evolve with the code, get reviewed in the same PRs, and survive every clone. - It's free for public repos and generous for private ones. A complete CI/CD pipeline costs zero rupees to start.
- 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.
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> │
└──────────────────┘
Triggered on every push to main. It does four things:
- Checks out the code. A fresh clone in a clean Ubuntu runner — no laptop state to leak.
- Builds two Docker images. A Go backend and an Nginx-served frontend. Both are multi-stage so the final images are small.
- 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:latesttag is what production pulls. - 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.
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 -fEvery 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
.envcheck fails loudly with a useful message instead of lettingdocker composeproduce a cryptic error about missing variables. docker compose pullbrings in the image you just built.up -donly recreates containers whose image actually changed — backend and DB don't get bounced if you only edited frontend HTML.docker image prune -fkeeps the EC2 disk from filling up with old image layers over weeks of deploys.
| 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.
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
cp .env.example .env # fill in DOCKERHUB_USERNAME (anything works for local)
docker compose up -d --buildOpen 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 volumeSame 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=60sNotes on this flow:
docker buildruns 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-imagecopies each image into the kind node's containerd.imagePullPolicy: IfNotPresenton the Deployments means k8s reuses the loaded image and never tries to pull from Docker Hub.kind-config.yamllives alongside the manifests for proximity, but it's akindconfig — not a Kubernetes resource — so it's fed tokind create cluster, notkubectl apply.
Inner-loop after editing code: make restart rebuilds the images, reloads them into the cluster, and rolls the Deployments.
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
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
| 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) |
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"- Docker Desktop must be running.
docker build,kind, andkubectlall 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
Pendingonmake up's first run. - Host port collision. If something else owns 8888 on the host, the cluster comes up but
curl localhost:8888fails. Free the port — or changehostPortink8s/kind-config.yamland re-runmake 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 restartrebuilds + reloads + rolls without ever touching Docker Hub. (Production EKS/GKE clusters do pull from a registry — that's the next chapter.)
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
Ingressrules 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 applyfrom the pipeline instead ofappleboy/ssh-action.
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
-
Fork this repo + clone locally.
make upshould work after that (see the Run on Kubernetes (kind) section). -
Add two secrets to your fork (
Settings → Secrets and variables → Actions):Secret Value DOCKERHUB_USERNAMEyour Docker Hub account name DOCKERHUB_TOKENa Docker Hub Personal Access Token with Read & Write scope -
Set the repo variable
DEPLOY_ENABLED = "true"(Settings → Variables → Actions). Until this istrue, CI builds without pushing and both CD workflows skip cleanly — the "dry run" state. -
Push any code change (not a
.md, not underk8s/ordocs/— 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.
-
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.
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.
- 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.ymldoes fire but the manifest is already pinned, so it no-ops and exits 0. That's the loop-protection working.
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
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 composewith manifests (Deployment, Service, Ingress). - Replace SSH-driven deploys with
kubectl applyfrom 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.
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.