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)

  1. Create the repo from the template. On GitHub, Use this template on alshedivat/al-folio, name the new repo tyy-x.github.io. Personal/user sites have to be named <username>.github.io — that’s a GitHub Pages requirement, not an al-folio one.

  2. Clone it locally. Standard git clone.

  3. Install Docker. On Debian 13:
    sudo apt install docker.io docker-compose
    sudo usermod -aG docker $USER
    sudo systemctl enable --now docker
    

    Log out and back in for the group change to take effect. The rationale, command by command:

    • Docker is a client-server system. The docker CLI 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.io package — installs the daemon and the docker CLI.
      • docker-compose package — installs the Compose v2 binary at /usr/libexec/docker/cli-plugins/docker-compose, which is what makes the modern docker compose subcommand work. It also drops a /usr/bin/docker-compose symlink to the same binary so the older docker-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 the docker group. Adding myself to that group lets me run docker commands without sudo. (Trade-off: anyone in docker group is effectively root-equivalent — they can mount / into a container and edit anything. Acceptable for my single-user laptop.)
    • systemctl enable --now docker — enable registers the daemon to auto-start on every boot; --now also starts it right now. Without --now I’d have to reboot before Docker worked.
  4. 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.

  5. Start the local dev server. From the repo dir:
    docker compose pull && docker compose up
    

    Site appears at http://localhost:8080 with live-reload — edits to most files trigger a rebuild and a browser refresh (_config.yml is the exception; that one needs a container restart).

    What each command does:

    • docker compose pull reads docker-compose.yml, finds the image: 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 up creates 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 -d to detach). If the image isn’t already cached, up will pull it automatically — making pull technically optional. The pattern in the QUICKSTART (pull && up) is just defensive: explicitly fetch any updates first.

    Could I do this with plain docker run instead? Yes — docker-compose.yml only 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.3
    

    Functionally 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.

  6. Push to GitHub. First push triggers the auto-build pipeline.

  7. Point GitHub Pages at the right branch. Settings → Pages → Source: Deploy from a branch → gh-pages. Without this step, GitHub will keep trying to build main itself 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.yml is 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:

  1. Boot a clean Ubuntu VM
  2. git clone my repo into it
  3. Install Ruby 3.2.2 and the al-folio gem dependencies
  4. Run bundle exec jekyll build --lsi — produces _site/, the rendered static HTML/CSS/JS
  5. Use the JamesIves/github-pages-deploy-action to push _site/ as a single commit to a separate branch called gh-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-pages branch in a temporary worktree
  • rsyncs _site/ into that worktree (with --delete, excluding CNAME, .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-deployment runs Jekyll on my source code. That source code uses al-folio’s plugins (jekyll-toc etc.) which aren’t on GitHub’s restricted allowlist → Liquid Exception: Unknown tag 'toc' → fail.
  • Source set to gh-pages → pages-build-deployment runs 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 in deploy.yml. (GitHub’s automatic pages-build-deployment does use a Docker container, but that’s a separate story.)
  • The Deploy workflow and pages-build-deployment are independent. They’re not orchestrated together; the only coupling is that the first writes to gh-pages and the second is triggered by gh-pages changing.
  • Pages doesn’t have to use Jekyll at all. Beyond the .nojekyll opt-out mentioned above, switching Source to “GitHub Actions” mode bypasses pages-build-deployment entirely — 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:

  • Google Gemini updates: Flash 1.5, Gemma 2 and Project Astra
  • Displaying External Posts on Your al-folio Blog