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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
jenkins ALL=(ALL) NOPASSWD: /usr/bin/rsync, /usr/bin/systemctl restart apache2
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 whole pipeline is defined as code and version-controlled with the site itself — two stages: the safety gate, then the deploy.
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
'
'''
}
}
}
}
}
It didn't work first try — and that's the interesting part. Each error taught me something about how these systems actually fit together.
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.
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.
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.
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.
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.
This pipeline keeps the very site you're on up to date. I build hands-on projects across AWS, Linux, Docker and CI/CD.