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:
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.
- frontend —
npm ci, followed bynpm run lintandnpm run build. The build step is useful here becausenext buildalso type-checks the application, so a TypeScript error is enough to fail the job. - backend —
go vet,go build, andgo test ./...for the Go API.
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.
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.
- First, the workflow copies
docker-compose.ymland theCaddyfileto the VPS using SCP. That means changes to the deployment configuration live in the repository and get deployed along with the application. - It then connects through SSH and runs
docker compose pull, followed bydocker compose up -d, using the commit SHA as the image tag. - Finally, it runs
docker image prune -fto clean up old images.
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
- the SHA tags make rollback pretty cheap. Since every deployment points to a specific commit SHA and the old images remain in GHCR, going back to an older version doesn't require rebuilding everything from scratch. I can deploy the older commit again.
- GHCR image names have to be lowercase.
github.repositoryisn't guaranteed to be lowercase, so the workflow converts it before using it as part of the image name. It's a tiny thing, but it's exactly the kind of tiny thing that can make you wonder why the workflow is suddenly failing. - the backup image isn't actually started during deployment. It lives behind a Compose profile, so
docker compose --profile backup pullcan fetch the new image without making a normaldocker compose up -dstart it. It's a one-shot job that i run separately when needed.
what it doesn't do yet
This setup is good enough for a personal website, but there are definitely things i could improve.
- deploy doesn't actually wait for CI. This is probably the biggest one. The two workflows are independent, so a push to
maincan start deploying even when the CI workflow is still running — or even if CI eventually fails. For a solo project, i'm willing to live with that for now, but making deployment depend on a successful CI run would be the proper solution. - there are no health checks or automatic rollbacks. If the new container starts successfully but the application itself is broken, the pipeline doesn't really know about it. At the moment, i find out by opening the website and noticing that something is wrong. The SHA tags make rolling back easy, but i still have to do it myself.
- there can be a small blip during deployment.
docker compose up -drecreates the containers in place. This isn't a blue-green deployment, so there can be a short period where the application isn't available. For a portfolio website that gets basically no traffic, that's a completely reasonable trade-off.
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.