TL;DR
I built CloudPulse — a dashboard that shows the live status of its own deployment pipeline. Push a commit to main, and within about 30 seconds it's built, tested, containerized, deployed, and the dashboard itself has updated to show the new build — pulling real data from Jenkins' API and GitHub's API, not a hardcoded array.
🔗 Live: cloudpulse-live.ip-irfanpasha5.workers.dev
💻 Code: github.com/IrfanPasha05/cloudpulse
No mocked screenshots in this post either — every image below is the actual dashboard, the actual Jenkins console output, the actual GitHub settings page, at the moment it happened.
Show Image
Why build this instead of another to-do app
Every DevOps tutorial I'd worked through stopped at "and now it's deployed" — a screenshot of a terminal saying SUCCESS, roll credits. I wanted to sit with the part that comes after that: what breaks when a real webhook fires against a real server, what a t3.micro actually chokes on, what happens when your own pipeline's post-cleanup step quietly murders the thing it just deployed.
So the rule I set for myself: the dashboard is not allowed to lie. If it says "Jenkins connected," it's because it just successfully called Jenkins' REST API. If Jenkins is down, it says so — visibly, with an amber dot instead of a teal one — rather than silently falling back to fake numbers and pretending everything's fine.
The architecture
One EC2 instance. Jenkins and the app share it — no separate build server, no container registry, no SSH-based remote deploy. This was a deliberate constraint, not a shortcut: on a single free-tier box, Jenkins can just run docker run locally once the image is built.
AWS EC2 · t3.micro · Ubuntu 22.04
git push main
webhook, instant
npm ci + test
docker build
docker run
build history API
HTTPS
repo status API
Developer
GitHub
Jenkins
Test
Image
CloudPulse:3000
Cloudflare Worker
You
A Cloudflare Worker sits in front for HTTPS and a stable URL — EC2 public IPs change on stop/start unless you pay for an Elastic IP, and I wasn't about to put a raw http:// IP address in a portfolio link.
The pipeline, stage by stage
groovy
pipeline {
agent any
stages {
stage('Install dependencies') { steps { sh 'npm ci' } }
stage('Test') { steps { sh 'npm test' } }
stage('Docker Build') { steps { sh 'docker build -t cloudpulse:latest .' } }
stage('Deploy') {
steps {
withCredentials([string(credentialsId: 'jenkins-api-token', variable: 'JTOKEN')]) {
sh '''
docker rm -f cloudpulse || true
docker run -d --name cloudpulse -p 3000:3000 --restart unless-stopped \
-e JENKINS_TOKEN=$JTOKEN \
cloudpulse:latest
'''
}
}
}
stage('Health Check') {
steps { sh 'sleep 5 && curl -f http://localhost:3000/healthz' }
}
}
post {
success { echo 'Deployed successfully' }
failure { sh 'docker rm -f cloudpulse || true' }
}
}
Five stages, triggered by nothing but a GitHub webhook. No one clicks "Build Now" in this workflow anymore.
Show Image
Bug #1: the pipeline that deployed and then un-deployed itself
The first fully green run was suspiciously anticlimactic. Every stage passed, including the health check. I opened the browser to admire my work.
ERR_CONNECTION_REFUSED.
The culprit was three lines at the bottom of the Jenkinsfile I hadn't thought hard enough about:
groovy
post {
always {
sh 'docker rm -f cloudpulse'
}
}
always means always — including the run that just succeeded. The container was healthy for roughly four seconds before its own pipeline deleted it. Jenkins had, technically, done exactly what I told it to.
The fix was to stop conflating "clean up" with "always run cleanup":
groovy
post {
success { echo 'Deployed successfully' }
failure { sh 'docker rm -f cloudpulse || true' }
}
Cleanup on failure only. Success means it stays up — which, in retrospect, was the entire point.
Bug #2: Jenkins restarting itself, mid-build, for no visible reason
With the webhook wired up, I pushed a real commit to test the automation. Watched the build start on its own — genuinely satisfying — and then watched it die in the Docker build stage with this buried in the console log:
Resuming build at Thu Aug 20 22:36:51 UTC 2026 after Jenkins restart
Jenkins had restarted itself. Mid-build. On a t3.micro with 1 GB of RAM, running Jenkins' own JVM, npm ci, and a Docker build concurrently was enough to trip the kernel's OOM killer — free tier constraints aren't a demo inconvenience, they're a legitimate constraint you have to design around, same as at any scale.
bash
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
2 GB of swap gave the box somewhere to put the spike instead of killing a process outright. Every build since has finished clean.
Bug #3: 403, from my own Jenkins server
Pipeline was rock solid. Time for the dashboard to stop lying about "24 deployments this week" and pull the real number:
GET /job/CloudPulse-CI-CD/api/json?tree=builds[number,result,timestamp,duration]
First call from inside the running container: Jenkins HTTP 403.
The mistake, in hindsight: I'd conflated "I can log into the Jenkins UI" with "this API token can read this job." Jenkins' matrix-based security treats those as separate concerns entirely — the token's user account didn't have Job/Read explicitly granted, so every API call bounced regardless of how valid the token itself was.
bash
the diagnostic that actually found it
curl -u : http://localhost:8080/job/CloudPulse-CI-CD/api/json
Running that by hand and getting the same 403 confirmed it wasn't a code bug — it was a permissions bug. Granted Job/Read explicitly to the token's user, and the dashboard's badge flipped from amber "Jenkins unreachable" to teal "Jenkins connected" on the next load.
Show Image
What "real data" actually means here
No hardcoded array pretending to be a deployment history. /api/dashboard calls out live on every request:
Jenkins' REST API — authenticated with a scoped token, returns real build numbers, real pass/fail results, real durations
GitHub's REST API — public, unauthenticated, returns the real "last pushed" timestamp for the repo
Node's os module — real EC2 load average and memory usage, read at request time
And if any of those three go quiet, the app degrades to clearly-labeled demo data rather than throwing a 500. A dashboard that crashes when its data source hiccups is arguably worse than one that's honest about showing a fallback.
Show Image
What I'd do differently at the next scale
This project intentionally lives at "one box, one app" scale — that constraint is what made the real bugs findable instead of buried under infrastructure complexity. The obvious next steps, in order:
Split Jenkins from the app host — push the image to Docker Hub/ECR, SSH-deploy to a separate EC2 instance, so a Jenkins restart can never take the live app down with it
Blue/green deploys — right now there's a few seconds of downtime during docker rm → docker run; a second container + traffic swap would close that gap
Alerting — a Slack ping on pipeline failure instead of me finding out by refreshing a browser tab
Elastic IP — so the Cloudflare proxy target never silently goes stale after an instance stop/start
The part that actually mattered
None of these three bugs are exotic. An always block instead of success/failure. A free-tier RAM ceiling. A permissions matrix I didn't read closely enough. Nothing here required deep systems expertise — it required reading logs carefully and not assuming the first plausible explanation was the right one.
That's most of what "real" DevOps work turns out to be. The pipeline YAML is the easy 20%. The other 80% is exactly what's in this post.
Repo: github.com/IrfanPasha05/cloudpulse — Jenkinsfile, Dockerfile, and the full commit history of every fix above are all there if you want to see the real diffs, not just the after-picture.
If you're mid-way through your own first real pipeline and something's inexplicably breaking — check your post block, check free -h, and check whether your API token can actually read what you're asking it to read. In that order.
Top comments (0)