Setting up this site (and the tech that powers it)
I rebuilt this site from scratch in May 2026. The last time I’d touched it was on a different machine, and al-folio had moved on enough that a clean install felt easier than chasing a rebase. This post is partly the steps I followed and partly notes-to-future-self about the moving parts that make a Jekyll site on GitHub Pages actually work.
Setup steps (assuming a fresh start)
-
Create the repo from the template. On GitHub, Use this template on
alshedivat/al-folio, name the new repotyy-x.github.io. Personal/user sites have to be named<username>.github.io— that’s a GitHub Pages requirement, not an al-folio one. -
Clone it locally. Standard
git clone. - Install Docker. On Debian 13:
sudo apt install docker.io docker-compose sudo usermod -aG docker $USER sudo systemctl enable --now dockerLog out and back in for the group change to take effect. The rationale, command by command:
- Docker is a client-server system. The
dockerCLI you type doesn’t do the work itself — it talks to a long-running background process called the Docker daemon (dockerd). The daemon is what actually pulls images, runs containers, and manages networks. So an “install Docker” really means “install the daemon, install the CLI, and make sure they can talk.”-
docker.iopackage — installs the daemon and thedockerCLI. -
docker-composepackage — installs the Compose v2 binary at/usr/libexec/docker/cli-plugins/docker-compose, which is what makes the moderndocker composesubcommand work. It also drops a/usr/bin/docker-composesymlink to the same binary so the olderdocker-compose ...(with hyphen) invocation still works. Both paths run the same v2 code; you need this package to get either.
-
-
usermod -aG docker $USER— the daemon listens on a Unix socket owned by thedockergroup. Adding myself to that group lets me rundockercommands withoutsudo. (Trade-off: anyone indockergroup is effectively root-equivalent — they can mount/into a container and edit anything. Acceptable for my single-user laptop.) -
systemctl enable --now docker—enableregisters the daemon to auto-start on every boot;--nowalso starts it right now. Without--nowI’d have to reboot before Docker worked.
- Docker is a client-server system. The
- Configure
_config.yml. Two mandatory fields for a personal site:url: https://tyy-x.github.io baseurl: # MUST stay empty (do not delete this line)Everything else (title, name, profile pic) is taste-driven and can wait.
- Start the local dev server. From the repo dir:
docker compose pull && docker compose upSite appears at
http://localhost:8080with live-reload — edits to most files trigger a rebuild and a browser refresh (_config.ymlis the exception; that one needs a container restart).What each command does:
-
docker compose pullreadsdocker-compose.yml, finds theimage:reference (amirpourmand/al-folio:v0.16.3), and downloads it from Docker Hub into the local image cache. For a pinned version tag like this, the image is content-addressed and immutable — so the download only happens once, on first run. -
docker compose upcreates a container from that image, applies the port mappings, volume mounts, and env vars from the compose file, then starts the container in the foreground (add-dto detach). If the image isn’t already cached,upwill pull it automatically — makingpulltechnically optional. The pattern in the QUICKSTART (pull && up) is just defensive: explicitly fetch any updates first.
Could I do this with plain
docker runinstead? Yes —docker-compose.ymlonly declares a single service, so there’s nothing compose-specific you couldn’t replicate by hand:docker run -d -p 8080:8080 -p 35729:35729 \ -v "$(pwd):/srv/jekyll" \ -e JEKYLL_ENV=development \ amirpourmand/al-folio:v0.16.3Functionally equivalent for now. But the YAML file documents the configuration declaratively, version-controlled with the rest of the project, and scales naturally to multi-container setups (e.g., adding a database service later). Compose for anything beyond a one-off
docker run. -
-
Push to GitHub. First push triggers the auto-build pipeline.
- Point GitHub Pages at the right branch. Settings → Pages → Source: Deploy from a branch →
gh-pages. Without this step, GitHub will keep trying to buildmainitself and fail (more on this below).
After step 7, every git push to main rebuilds and redeploys the site automatically.
What’s actually happening under the hood
The setup feels like magic — push, wait three minutes, site is live. But there’s a precise division of labor between Docker, GitHub Actions, the gh-pages branch, and GitHub Pages. None of it makes sense in isolation. I’ll build up the picture concept by concept.
Static sites and static site generators
A static site is a website made entirely of pre-built files: HTML, CSS, JavaScript, images. The web server’s only job is to send those files to browsers — there’s no database query per request, no per-user computation, no server-side code execution. Every visitor gets the same response (any personalization happens client-side via JavaScript).
The contrast is a dynamic site like Twitter or Gmail, where the server runs code on every request to assemble the response from databases and user state.
Static sites are massively popular for blogs, docs, and portfolios because they’re fast (just file sends), cheap (any static host or CDN), secure (no DB to attack, no server code to exploit), and trivially scalable (a CDN handles 1 visitor or 1M visitors the same way).
The catch: writing raw HTML for every blog post is miserable. That’s where static site generators (SSGs) come in — tools that turn nice source files (Markdown posts, templates, data files) into a folder of static HTML at build time. You author in Markdown, run the SSG, get a _site/ folder full of plain HTML/CSS/JS ready to serve.
The big SSGs: Jekyll (Ruby), Hugo (Go), Astro (JS), Next.js (in static-export mode), Eleventy, MkDocs, etc. al-folio is built on Jekyll.
Docker, containers, images
Docker packages an application together with everything it needs to run — OS libraries, language runtime, system tools, dependencies — into a self-contained unit called a container. The container runs as a normal process on your host, but with its own isolated filesystem, network, and process tree.
The vocabulary worth pinning down:
- An image is a frozen, read-only snapshot of a filesystem with everything pre-installed. Built once from a recipe (a
Dockerfile), shared via a registry (Docker Hub being the public default). - A container is a running instance of an image — like a process, but with its own isolated
/, its own network namespace, its own view of system resources. - A Dockerfile is the recipe for building an image (
FROM ruby:slim,RUN apt-get install …,COPY …). -
docker-compose.ymlis the recipe for running one or more containers with specific configuration (port mappings, volume mounts, env vars).
For my site, this means I never have to install Ruby, Jekyll, ImageMagick, nbconvert, or Bundler on Debian directly. The image amirpourmand/al-folio:v0.16.3 already contains all of them, pre-built by the al-folio maintainers and pushed to Docker Hub. docker compose up creates a container from that image, mounts my repo into it at /srv/jekyll, and runs Jekyll inside. I get localhost:8080 working without polluting my host system.
The deeper lesson: containers solve the “works on my machine” problem by making “my machine” identical to “their machine” by construction.
GitHub Pages
GitHub Pages is a static-file web hosting service bundled into GitHub. It serves files from a chosen branch of a repo at <username>.github.io[/<reponame>] (for a user site). Free for public repos, automatic HTTPS via Let’s Encrypt, custom-domain support, generous bandwidth limits.
It also has a built-in Jekyll capability: by default, when you point Pages at a branch, it tries to run Jekyll on those files first using its own restricted environment — the github-pages gem at a fixed version (currently v232), Jekyll 3.10, and a small allowlist of plugins. This is meant as a convenience for people who don’t want to run their own build, but it can’t handle modern Jekyll setups like al-folio (which uses Jekyll 4.x and plugins like jekyll-toc that aren’t on the allowlist).
The relation between Jekyll and GitHub Pages, then: Jekyll is the SSG; GitHub Pages is the host. They were designed to work together (GitHub built Pages around Jekyll), but they’re separable. You can use Jekyll without Pages (host the output anywhere) and Pages without Jekyll (serve any static files or use another SSG).
al-folio’s strategy is to run its own full Jekyll build, then hand Pages just the finished static output. That’s where CI/CD comes in.
CI/CD — the idea behind the rest
CI/CD stands for Continuous Integration / Continuous Deployment. The core idea: every change you make is automatically built and (if the build passes) released, by a machine that doesn’t share your laptop’s quirks. No “deploy guy”, no manual SSH, no “works on my machine” surprises.
For this site, the cycle is:
- I push a commit
- A clean machine on GitHub’s infrastructure clones my repo
- It builds the site
- If the build succeeds, the static output is published
- If it fails, the live site stays at the last good version and I get a red ✗
The platform that runs this for me is GitHub Actions.
GitHub Actions and workflows
GitHub Actions is GitHub’s built-in service for running code on its servers in response to events in a repository (a push, a pull request, a manual click, a scheduled time). It hands you a fresh Ubuntu VM, runs whatever commands you specify, then throws the VM away.
To use GitHub Actions, you define workflows — automations defined in YAML files under .github/workflows/. Each file declares:
- When it runs (
on: push,on: schedule,on: workflow_dispatch, etc.) - What machine it runs on (
runs-on: ubuntu-latest) - What steps to take, top to bottom
al-folio ships several workflows. The one that matters for deployment is deploy.yml. Its job, in plain English:
- Boot a clean Ubuntu VM
-
git clonemy repo into it - Install Ruby 3.2.2 and the al-folio gem dependencies
- Run
bundle exec jekyll build --lsi— produces_site/, the rendered static HTML/CSS/JS - Use the
JamesIves/github-pages-deploy-actionto push_site/as a single commit to a separate branch calledgh-pages(creating the branch if it doesn’t exist; force-pushing to overwrite the previous deploy)
That last step is the bridge to GitHub Pages.
gh-pages — the published-output branch
gh-pages is a Git branch like any other, but by convention it holds the built, ready-to-serve version of the site, not the source code. Its content is the output of step 4 above: rendered HTML, minified CSS, optimized JS, copied images.
The reason for the separation: my main branch contains Markdown, Liquid templates, SCSS, BibTeX — none of which a browser can render directly. The gh-pages branch contains the pre-rendered HTML that’s ready to ship.
Importantly, I never edit gh-pages directly. The Deploy workflow writes to it; my job is to edit main. Looking at what the JamesIves action actually does on the runner (visible in the workflow’s Deploy step logs), it:
- Checks out an orphan
gh-pagesbranch in a temporary worktree -
rsyncs_site/into that worktree (with--delete, excludingCNAME,.nojekyll,.git, etc.) - Commits and force-pushes to
gh-pages
Force-push because the previous deploy is irrelevant — the branch isn’t a history of the site, it’s a snapshot of the latest build.
The Source switch, and the pages-build-deployment quirk
Once gh-pages exists, you tell GitHub Pages where to look: Settings → Pages → Source: Deploy from a branch → gh-pages. This is the configuration knob that closes the loop.
Now here’s the part that confused me. Watching the Actions tab, I saw two workflows fire on every push:
- Deploy site (the al-folio one in
.github/workflows/deploy.yml) - pages-build-deployment — which doesn’t appear anywhere in
.github/workflows/
That second workflow is GitHub’s automatic Pages build, hosted by GitHub, not in my repo. It’s implemented as a Docker-based action (ghcr.io/actions/jekyll-build-pages:v1.0.13) that GitHub fires whenever the branch configured as Pages source changes. So:
- Source set to
main→pages-build-deploymentruns Jekyll on my source code. That source code uses al-folio’s plugins (jekyll-tocetc.) which aren’t on GitHub’s restricted allowlist →Liquid Exception: Unknown tag 'toc'→ fail. - Source set to
gh-pages→pages-build-deploymentruns Jekyll on my built output. That output is plain HTML files with no front-matter, no `` tags, no plugin requirements. Jekyll has nothing to do — it passes the files through to its output and finishes in ~50 seconds → success.
It’s the same Jekyll process in both cases. The difference is purely the input: source code that needs plugins to render, vs. pre-rendered HTML that just needs to be copied through.
There’s also an explicit way to skip the Jekyll build entirely: a .nojekyll file at the root of the served branch tells Pages to leave the files alone. al-folio’s deploy doesn’t add one — the no-op behavior happens naturally with pre-built HTML — but it’s the standard signal if I ever want to make the skip explicit.
Putting it together
Here’s the whole pipeline:
my laptop GitHub
───────── ──────
docker compose up
│
▼
container w/ Ruby + Jekyll
│
▼
preview at localhost:8080
git push main ─────────────────▶ Deploy workflow (.github/workflows/deploy.yml)
┃ runs natively on Ubuntu runner
┃ (no container)
┃ bundle exec jekyll build → _site/
▼
JamesIves action: rsync _site → gh-pages,
force-push
│
▼ (branch update triggers…)
pages-build-deployment workflow
┃ runs in jekyll-build-pages container
┃ Jekyll over pre-built HTML = no-op
▼
GitHub Pages serves gh-pages
▼
tyy-x.github.io
Each box is doing one job. The chain of trust: my local Docker preview matches what CI will produce; the Deploy workflow commits the result to gh-pages; the gh-pages update triggers GitHub’s auto-build, which essentially no-ops; Pages serves what’s there. Break any link and the site stops updating, but at least it’s clear which link broke.
A few clarifications worth highlighting, since they tripped me up:
- Docker shows up on my laptop only. The Deploy workflow runs Jekyll natively on the Ubuntu runner — there’s no
container:field indeploy.yml. (GitHub’s automaticpages-build-deploymentdoes use a Docker container, but that’s a separate story.) - The Deploy workflow and
pages-build-deploymentare independent. They’re not orchestrated together; the only coupling is that the first writes togh-pagesand the second is triggered bygh-pageschanging. - Pages doesn’t have to use Jekyll at all. Beyond the
.nojekyllopt-out mentioned above, switching Source to “GitHub Actions” mode bypassespages-build-deploymententirely — your workflow takes full responsibility for producing the artifact. al-folio uses neither path; it works out of the box because Jekyll-over-already-built-HTML is harmless.
That’s the whole picture as I now understand it.
Enjoy Reading This Article?
Here are some more articles you might like to read next: