Building a Personal Portfolio with Jekyll and Shipping It to Production

When the nbn slows to a crawl at 7pm because the whole street is streaming the AFL, I want my portfolio to load in under two seconds on a 3G connection in the middle of a Perth suburb. That requirement quietly pushed me towards a static site, and eventually towards Jekyll. The site you are reading this on is the result of months of tinkering, rewriting, and finally shipping something I am happy to put in front of recruiters and clients alike.

Static site generators have a particular reputation. They are praised for blogs, maligned for anything dynamic, and confusing for anyone who has spent the last decade living inside WordPress or a .NET CMS. After a few weeks of testing, I found Jekyll struck a sensible balance. Liquid templates are quick to learn, the plugin ecosystem is mature enough to handle RSS, sitemaps, and JSON-LD without much fuss, and the output is plain HTML that survives pretty much any hosting platform you throw at it.

The version I eventually deployed uses a custom theme I open-sourced on GitHub, runs on GitHub Pages for the demo, and lives on a small VPS for the production version behind a custom domain registered through a Brisbane-based registrar. This article walks through how I put it together, what I would do differently, and a few decisions that are especially relevant if your visitors are sitting on the other side of the planet from your build server.

Choosing a Static Site Generator Over a CMS

A traditional CMS gives you an admin panel, a database, and the comforting knowledge that you can log in from any browser and change a typo without redeploying. That is also the problem. Once you have a database, you have patches to apply, backups to schedule, and a long tail of plugin vulnerabilities that will surface in a vulnerability scan sooner rather than later. The Australian Cyber Security Centre publishes weekly advisories that frequently include WordPress and Drupal core issues, and if you are a solo operator that surface area is hard to justify for a portfolio that only changes a few times a year.

Jekyll removes almost all of that. There is no database, no admin panel, and no runtime that an attacker can poke at. The content lives as Markdown files in a Git repository, the build process generates static HTML, and the only thing the world sees is a folder of files on a CDN. Updates happen locally, get committed to Git, and a push triggers a fresh build. It is a workflow that fits naturally with how I already work on other code projects.

The trade-off is that anything genuinely dynamic has to be handled out of band. A contact form lives as a serverless function, analytics are pulled in client-side, and comments are deliberately disabled in favour of social links. For a portfolio that exists to point at projects and case studies rather than to drive a community, that is a clean trade to make. It also keeps the running cost in Australian dollars down to the price of a domain and a CDN plan.

Designing for Visitors at the End of a Long Cable

Most of my hypothetical visitors are in Sydney, Melbourne, and Brisbane, with a long tail in Adelaide and Perth. That distribution matters because latency from a Chicago or Frankfurt data centre to a Telstra customer in Parramatta is materially worse than latency from a Sydney point of presence. The first version of my portfolio was hosted in the United States, and the time to first byte during AEST business hours hovered around 400ms. After moving the build to a Singapore edge with an Australian origin shield, that figure dropped to under 80ms.

The visual design followed the same logic. I deliberately kept the colour palette small, used system fonts as the primary stack, and avoided heavy web fonts that have to be fetched from overseas CDNs. Each page is under 100KB transferred, which matters for nbn customers on slower FTTN connections where a single large font file can stall a render. I also dropped every JavaScript dependency I could, leaving only a small bit of code for the dark mode toggle and the mobile menu.

SEO was another area where the Australian audience shaped choices. I set the site locale to en-AU, used Australian English spelling in the metadata descriptions, and structured the data so that the JSON-LD output would make sense to local recruiters searching for terms like "full stack developer Sydney" rather than generic international phrases. The robots file explicitly allows the major Australian and global crawlers, and the sitemap is segmented so that recruiters can crawl case studies separately from blog posts.

Setting Up the Local Development Environment

My local environment is nothing fancier than a recent Ruby, Bundler, and Jekyll installed through a Gemfile pinned to specific versions. On Windows I tend to use the RubyInstaller build, but the production build environment is Linux and I try to keep the two as close as possible to avoid the classic "works on my machine" problem. The Gemfile lists Jekyll itself, a handful of plugins for sitemaps and RSS, and a couple of theme-specific gems that handle asset fingerprinting and image processing.

Image processing deserves a mention. Jekyll's built-in support for responsive images uses the ImageMagick or libvips backend, and the difference in build time between the two is significant. libvips is roughly five times faster on the same set of source photographs, which matters when I rebuild the site after editing a single case study. I generated the source images at 2x density from Lightroom, then let Jekyll produce webp and avif variants for modern browsers alongside traditional jpegs for older clients.

For content, I keep two folders in the repository: _posts for the blog and _portfolio for case studies. Both use Markdown front matter with a small set of custom fields like client, role, and stack. The build process reads those fields, renders them into structured data, and outputs a JSON file that powers the filters on the case study index. Writing a new case study is a matter of dropping a file into the folder and pushing to Git.

Automating the Build and Deployment Pipeline

The pipeline that takes a Markdown file to a live page runs through GitHub Actions. A push to the main branch triggers a job that checks out the repository, installs Ruby and Bundler, runs bundle install, executes jekyll build, and then uploads the resulting _site folder to the production target. The whole thing takes around 90 seconds from commit to live, which is fast enough that I rarely think about it.

For the production environment, I settled on a small VPS in Sydney rather than a managed platform. The reason is partly cost. A basic VPS with a Sydney data centre costs me less than a per-seat SaaS plan once you convert the pricing into Australian dollars and add GST. Control is the other factor. I run Caddy as the reverse proxy, which handles automatic HTTPS through a DNS challenge, and the static files are served directly from disk with long cache headers. The Caddyfile is checked into the repository so the server configuration is reproducible.

Deploys go through a small script that uses rsync over SSH to push the new build to the VPS, then reloads Caddy. I keep the previous build on disk so a bad deploy can be rolled back by symlink swap in seconds. There is a health check that hits the homepage after each deploy, and if the response code is anything other than 200 the script fails the GitHub Action and leaves the previous version in place. A failed deploy should never silently break the site.

Hosting, Domain, and Ongoing Maintenance

The domain is registered through a Brisbane-based registrar and uses their DNS, which has an anycast network with Australian points of presence. The CDN in front of the VPS is Cloudflare, configured to cache aggressively but to bypass the cache for the small handful of paths that need to be dynamic. Origin certificates keep the connection between Cloudflare and the VPS encrypted without paying for a traditional certificate authority.

Maintenance is the part I deliberately designed to be boring. Every quarter I run bundle update and check that the Jekyll version and plugins are still receiving security patches. Every month I look at the access logs to make sure nothing unexpected is hitting the origin. And every time I add a new case study I run a Lighthouse audit on the published page to confirm that performance, accessibility, and SEO are still in the green. None of that takes more than an hour, which is roughly the time it takes me to write a single blog post.

If something breaks, the rollback is a single Git revert and a redeploy. If the VPS dies, the build artifacts are also stored in a Cloudflare R2 bucket, so the site can be served directly from object storage while the VPS is replaced. The whole recovery story takes longer to read than to actually execute.

Practical Recommendations for Your Own Build

A short list of things I would suggest before you start:

If you want to see the end result, the source code for this site is open source and the theme I extracted from it is available on GitHub. Fork it, swap in your own content, push to a remote, and you have a portfolio that loads fast on a 4G connection in the middle of Bourke and survives the next decade of web platform churn.