Case Study · DevOps

Building an automated
CI/CD pipeline for my portfolio

One git push ships this site to production — no manual steps. Here's how I wired GitHub, Jenkins, and an AWS server together, and the real problems I solved along the way.

JenkinsGitHub WebhooksAWS EC2 LinuxSSHrsyncApachesudoers
Live · auto-deploys on every push
The idea

Push code → site updates itself

Manually copying files to a server and restarting it is slow and error-prone. I wanted the professional workflow: commit my changes, and have the live site update automatically — with a safety gate so not every push goes out.

GitHub

git push → webhook

Jenkins

build · gate · deploy

AWS EC2

rsync + Apache

Visitors

site is live
How it works

The pipeline, stage by stage

01

Push & webhook

I push a commit to main. GitHub instantly sends a webhook — a small HTTP "something changed" message — to my Jenkins server, which kicks off a build.

02

Checkout & safety gate

When the build starts, Jenkins automatically checks out (clones) the private repo from GitHub over SSH using a stored credential — that's how it gets the Jenkinsfile in the first place. My first stage then reads the latest commit message and only deploys if it contains the word "deploy" — so work-in-progress pushes never go live by accident.

03

Deploy over SSH

Jenkins connects to the portfolio server over SSH — using a key stored as a Jenkins credential — pulls the latest code, and rsyncs it into the Apache web root, copying only what changed and removing stale files.

04

Serve

A final systemctl restart apache2 reloads the web server, and the new version of the site is live for visitors — usually within seconds of the push.

Under the hood

The parts that made it solid

Getting a deploy to run is easy. Getting it to run securely and reliably is where the real engineering is. These are the pieces I'm proudest of.

GitHub webhooks

Instead of Jenkins constantly asking "any changes yet?", GitHub tells it the moment I push. I locked the inbound firewall to GitHub's published IP ranges so the endpoint isn't exposed to the whole internet.

Two SSH connections, no passwords

Jenkins authenticates over SSH twice: to GitHub to clone the private repo, and to the portfolio server to deploy. Both private keys are stored as Jenkins credentials (never in the repo) — separate, dedicated key pairs that can each be revoked on their own.

Least-privilege user

Deploys run as a dedicated jenkins Linux user — not the all-powerful default account. It's added to a deploy group that owns the web files, so it can update the site without being root.

Scoped passwordless sudo

The trickiest part. The deploy needs root for two commands, but Jenkins has no terminal to type a password. I wrote a sudoers rule granting passwordless sudo for only those two commands — nothing more.

Spotlight: "escaping the terminal" with sudoers

When you run sudo by hand, Linux can pop up a password prompt because you have a terminal. Jenkins runs commands non-interactively — there is no terminal — so sudo fails with "a terminal is required to authenticate." The fix isn't to fake a terminal; it's to remove the need to authenticate at all, but only for the exact commands the deploy needs:

/etc/sudoers.d/jenkins-deploy
jenkins ALL=(ALL) NOPASSWD: /usr/bin/rsync, /usr/bin/systemctl restart apache2
Why this is the right call: the jenkins user can run, without a password, only rsync and "restart apache2" — it can't open a root shell or run arbitrary admin commands. That's the principle of least privilege applied to automation: give it exactly what it needs, and nothing else.
The code

The Jenkinsfile

The whole pipeline is defined as code and version-controlled with the site itself — two stages: the safety gate, then the deploy.

Jenkinsfile
pipeline {
    agent any
    stages {

        // Note: before these stages run, Jenkins auto-checks-out (clones)
        // the repo from GitHub over SSH — that's how it gets this Jenkinsfile.

        // 1 — only deploy when the commit message says so (repo is already cloned)
        stage('Check Commit Message') {
            steps {
                script {
                    def msg = sh(script: 'git log -1 --pretty=%B',
                                 returnStdout: true).trim()
                    echo "Latest commit: ${msg}"
                    if (!msg.toLowerCase().contains('deploy')) {
                        error "Commit must contain 'deploy' to ship."
                    }
                }
            }
        }

        // 2 — ship it over SSH
        stage('Deploy') {
            steps {
                sshagent(credentials: ['Jenkin-myportfolio-aws']) {
                    sh '''
                        ssh -o StrictHostKeyChecking=no jenkins@<server> '
                            cd ~/MyPortfolio &&
                            git pull origin main &&
                            sudo rsync -a --delete ~/MyPortfolio/ /var/www/html/ &&
                            sudo systemctl restart apache2
                        '
                    '''
                }
            }
        }
    }
}
The journey

Real errors, and how I debugged them

It didn't work first try — and that's the interesting part. Each error taught me something about how these systems actually fit together.

failed to connect to host

The webhook couldn't reach Jenkins

GitHub connects inbound, so the server's firewall was blocking it. I opened the port — but only to GitHub's official webhook IP ranges, not the whole internet — and added an Elastic IP so the address stops changing on restarts.

delivered, but no build ran

Webhook arrived, nothing happened

A webhook triggers a poll, and Jenkins only builds on a genuinely new commit. The job also needed the trigger enabled and the GitHub project URL set so it could match the repo. Learned exactly how the trigger plumbing works.

Permission denied (publickey)

Jenkins couldn't SSH in

The public key wasn't installed for the right user, and SSH silently rejects keys when file permissions are too loose. Fixed the authorized_keys ownership and the strict 700/600 permissions.

files copied, site didn't change

The deploy "worked" but nothing updated

The existing web files were root-owned and read-only to the group, so the copy couldn't overwrite them. This is what pushed me to deploy with a proper root-level rsync and fix the web-root group ownership.

sudo: a terminal is required to authenticate

The classic non-interactive sudo wall

It worked when I tested by hand but failed in Jenkins — because my manual login was a different user with a terminal. The real fix was the scoped NOPASSWD sudoers rule above. The most satisfying one to crack.

The result

What I ended up with

1
command to ship
(git push)
~30s
push → live
on the site
0
passwords or
manual steps
2
root commands
(scoped, no more)

What I took away

Want to see more of my work?

This pipeline keeps the very site you're on up to date. I build hands-on projects across AWS, Linux, Docker and CI/CD.