All posts

how i create CI CD pipeline for my portfolio website

every push to main ends up on my vps a few minutes later: github actions runs the checks, builds the docker images, pushes them to ghcr, and rolls the new version onto the server. nothing fancy, but it saves me from doing the same deployment dance every time.

ci/cdgithub actionsdockervpsdevops
SN
Satria Nusa
AUG 25, 2026 · 6 MIN READ
how i create CI CD pipeline for my portfolio website

This portfolio website is something i keep messing with.

Sometimes i'm adding a new feature, sometimes i'm fixing a bug, and sometimes i'm just looking at the code and thinking that a certain part could probably be written better. So the code changes quite often, even though this is obviously nowhere near the scale of an enterprise application.

But changing the code is only half of it. At some point that code has to actually make its way onto the server, and doing that manually every time gets annoying surprisingly quickly.

the pipeline's only job is to make shipping boring.

That's basically why i set up CI/CD for this site. I use GitHub Actions for the automation, GitHub Container Registry (GHCR) for the Docker images, and a small VPS to actually run everything.

The setup is only two workflow files: ci.yml runs the checks whenever i push something, while deploy.yml handles the actual deployment when something gets pushed to main.

So the general flow looks like this:

usergithubghcrvpsalt[push is to main][any other branch]git pushrun CI checksbuild & push 3 images (:latest + :sha)images storedscp docker-compose.yml + Caddyfilessh: roll to the new imagesdocker compose pull (tag = commit sha)fresh image layersdocker compose up -d + prunerollout script exits 0deploy workflow green, site is liveCI status only — nothing deploysa push, from commit to live site

One important detail: CI and deployment are actually two separate workflows. They are both triggered by the same push, so deploy isn't currently waiting for CI to finish. I'll get back to that later.

the CI half: checking things before i forget

Every push, regardless of branch, kicks off the CI workflow. Pull requests do the same thing. The frontend and backend jobs run in parallel.

There's also a small GitHub Actions feature that i ended up liking quite a lot: concurrency groups.

The CI workflow puts each branch into its own concurrency group and uses cancel-in-progress: true. So if i push a commit and then immediately push another one before the first CI run finishes, GitHub cancels the old run and checks the newer commit instead.

There's not much point spending several more minutes testing commit A when commit B has already replaced it.

usergithubopt[a newer push lands on the same branch]push commit Astart CI run for Apush commit Bcancel A's run, start B'sstatus of the newest runci concurrency: newer push wins

the deploy half: main is where things get real

The deploy workflow only runs when something is pushed to main. Feature branches can run CI all they want, but they never get anywhere near the VPS.

The deployment itself is split into two jobs.

First, build and push the images.

There are three Docker images involved: the Go backend, the Next.js frontend, and a small backup container. They all get pushed to GitHub Container Registry.

Every image gets two tags: :latest and the Git commit SHA that triggered the deployment.

The SHA tag is the more important one. The deployment uses that exact tag instead of simply saying "give me whatever is latest", which means i always know exactly which version is running on the server.

Docker layer caching is also enabled through the GitHub Actions cache, so things that haven't changed don't have to be rebuilt from scratch every time.

Then, roll the images onto the VPS.

That last part is more important than it sounds. This is a tiny VPS, and Docker images can quietly eat disk space if i just keep deploying without cleaning anything up.

The server's .env file is intentionally not part of this process. It stays on the VPS, and the workflow never copies it anywhere. The repository contains the configuration needed to run the application, but the actual secrets stay on the machine.

Deployments have their own concurrency group too, but this time cancel-in-progress is set to false.

Two deployments shouldn't happen at the same time, but cancelling a deployment halfway through is a much worse outcome than simply letting it finish. So if i push another commit while a deployment is already running, the newer deployment waits for the first one to finish.

a few tiny things i almost didn't think about

what it doesn't do yet

This setup is good enough for a personal website, but there are definitely things i could improve.

And that's pretty much the whole thing.

Two YAML files, three Docker images, one VPS, and a couple of GitHub Actions jobs are enough to turn a git push into a live deployment a few minutes later.

It's nowhere near an enterprise deployment platform, and honestly, it doesn't need to be. I built this because i don't want deploying my own website to be an event. I want to push the code, let the machines do their thing, and move on.

In that sense, the pipeline is doing exactly what i wanted it to do: make shipping boring.

SN
Satria Aluh Perwira Nusa

Fullstack engineer, 4 years across backend and product. I write about what I ship — or ask my AI assistant anything.

Get in touch
← OLDER
automating disk rebuild status checks
© 2026 Satria Nusa · satrianusa.dev