Writing

Self-hosting a static site with Kamal Skiff

A step-by-step walkthrough of how this site is set up, installing Kamal Skiff, scaffolding a project, configuring nginx and the deploy, sharing headers and footers with SSI, and adding a Markdown-driven blog with about fifty lines of Ruby. Written for someone who has never used Skiff before.

This is a step-by-step guide to how I host this site. The goal is to take you from “I have a server and a domain” to “I’m writing blog posts in Markdown and pushing them live with git push”, without skipping any steps that an experienced person might think of as obvious.

What you’ll end up with:

  • A static site running in a Docker container on your own server.
  • Automatic TLS via Let’s Encrypt at the origin.
  • Shared headers, navs, and footers across pages (no copy-paste HTML).
  • A blog where you write Markdown locally, run one command, and push to publish.
  • A deploy model where most changes are just git push, no rebuild, no CI, no static-site framework.

The tool doing the heavy lifting is Kamal Skiff, a Ruby gem from Basecamp built on top of Kamal. It’s a thin layer over nginx-in-a-container that’s designed specifically for static sites. If you’ve used Kamal for a Rails app, Skiff will feel immediately familiar. If you haven’t, it’s a manageable first introduction to Kamal because there’s no application server to think about, just files and nginx.

What you need before starting

You’ll need a few things in place before any of the steps below will work. None of them are exotic, but it’s worth checking them off first so you don’t get stuck halfway through.

A Linux server you can SSH into. I use a CX22 at Hetzner (€4/month, 2 vCPU, 4GB RAM, plenty for a static site). Any Linux box with SSH and the ability to run Docker works. You don’t need to install Docker yourself, Kamal does that on first deploy.

A domain you control. You’ll need to point DNS at your server’s IP. I use Cloudflare for DNS because the proxy features (caching, DDoS protection, easy TLS) are useful, but plain DNS at your registrar works too.

Ruby installed locally. Skiff is a Ruby gem, so you need Ruby on your laptop to run the skiff command. Any recent version (3.0 or newer) is fine. If you don’t have it, use rbenv or mise, both make managing Ruby versions much easier than the system Ruby.

ruby --version
# ruby 4.0.5 or similar, anything 3.0+ works

Git, an SSH key, and a GitHub account. Skiff deploys by having the container pull updates from a git repository, so the code needs to live on GitHub (or any git host the server can reach). If you can git push to GitHub already, you’re set.

Docker on your laptop (optional but recommended). Skiff can build the container image locally and push it to a registry on the server, so strictly speaking you can skip local Docker if you let Skiff handle remote builds. I find local Docker easier when something goes wrong because you can rebuild and test images without involving the server.

That’s it. No Node, no Go, no Python, no static-site framework.

Step 1: Install Kamal Skiff

Skiff is a Ruby gem. Install it globally with:

gem install kamal-skiff

If you’re using rbenv or mise with a per-project Ruby, install it into the version of Ruby you’ll use for your site. After install, verify it:

skiff version
# 0.5.0 (or similar)

The skiff binary now lives somewhere in your $PATH. If skiff version says command not found, your gem bin directory isn’t on your path, gem env home will show you where gems are installed, and you’ll want the bin/ subdirectory of that on your path.

Step 2: Scaffold a new project

In an empty directory, run:

skiff new mysite

This creates the following files:

mysite/
├── Dockerfile
├── serve                       # entrypoint that runs inside the container
├── config/
│   ├── deploy.yml              # Kamal deploy config
│   ├── deploy.staging.yml      # optional staging environment
│   └── server.conf             # nginx server config
├── public/                     # everything in here gets served
│   ├── index.html              # placeholder home page
│   ├── up.html                 # healthcheck endpoint
│   ├── assets/                 # static assets
│   │   ├── images/
│   │   ├── javascripts/
│   │   └── stylesheets/
│   ├── _includes/              # SSI partials (more on these later)
│   │   ├── header.html
│   │   └── footer.html
│   └── _errors/                # custom error pages
│       └── 404.html
├── .kamal/
│   └── secrets                 # template for environment secrets
├── .gitignore
└── .dockerignore

Take a minute to look at each file. The whole scaffold is small enough to read in one sitting. The only files you’ll have to edit before deploying are config/deploy.yml and .kamal/secrets, everything else has reasonable defaults.

The two folders to know about:

  • public/ is the site root. Everything in it is served by nginx exactly as-is. This is where your HTML, CSS, images, and (later) generated blog posts go.
  • config/ is your deploy and server configuration. Nothing here is served to visitors.

Folders starting with an underscore inside public/ (_includes/, _errors/) are special, they’re meant for SSI partials and won’t be publicly reachable when the site is live. That’s enforced by an nginx rule in server.conf that returns 404 for any path starting with /_.

Step 3: Add your own content

Before configuring anything else, put your actual site in public/. The scaffold already gives you a place for everything:

  • public/index.html, your home page (replace the placeholder)
  • public/assets/stylesheets/, your CSS files
  • public/assets/images/, your images
  • public/assets/javascripts/, any progressive-enhancement JS

If you’re starting from scratch, the placeholder index.html Skiff creates is enough to deploy and test with. You can come back and put real content in later.

Step 4: Run the site locally

Before touching any configuration, see what you have in a browser:

skiff dev

This starts a local Docker container running the same nginx setup as production, serving your public/ directory at http://localhost:3000. SSI includes are processed, gzip works, the error page works, clean URLs work. It’s the real thing, not an approximation.

You need Docker running locally for this. If you don’t have it, any static file server pointed at public/ will show your HTML and CSS, but SSI directives will render as HTML comments instead of being processed. For quick checks that’s fine; for testing includes you need skiff dev.

Leave it running in a terminal while you work. Changes to files in public/ are served immediately on the next request (no restart needed), since skiff dev bind-mounts your local public/ directory into the container.

Step 5: Understand the nginx config

config/server.conf is the nginx server block that gets loaded inside the container. The Skiff default is minimal:

server {
  listen 80;

  # Configure URL rewrites
  # rewrite ^/old-pricing$ /pricing last;

  # Only needed if there's no CDN in front
  gzip on;

  # Turn on SSI with etags and long values
  ssi on;
  ssi_last_modified on;
  ssi_value_length 1024;

  # All web accessible files live here
  root /site/public;

  # Map error pages
  error_page 404 /_errors/404.html;

  # Folders starting with _ are not publicly accessible
  location ~ ^/_ {
    internal;
  }

  # Make /up look first for /up.html then /up/index.html
  location / {
    try_files $uri $uri.html $uri/index.html =404;
  }
}

Reading top to bottom:

  • listen 80: nginx listens on port 80 inside the container. Kamal’s proxy (kamal-proxy) handles TLS termination on port 443 outside the container and forwards to port 80 inside.
  • rewrite (commented out): an example of how to add URL rewrites. Uncomment and adjust when you need to redirect an old path to a new one. The last flag tells nginx to re-evaluate the rewritten URL against the location blocks rather than returning a redirect to the browser.
  • gzip on: compress text responses. As the comment says, this is only needed if there’s no CDN in front. If you put Cloudflare (or similar) in front, the CDN handles compression for visitors, but gzip here still helps for direct origin requests and local development.
  • ssi on and friends: turn on Server-Side Includes, which lets HTML files compose from partials at request time. ssi_last_modified on preserves the Last-Modified header so caches still work correctly with SSI. ssi_value_length 1024 raises the max length for SSI variable values (the default 256 is too short for longer page titles or descriptions). We’ll use SSI in Step 11.
  • root /site/public: the path inside the container that nginx serves from. The serve script syncs your repo’s public/ to /site/public on every git poll, so this stays consistent.
  • error_page 404 /_errors/404.html: when something 404s, serve the custom 404 page from _errors/.
  • location ~ ^/_ { internal; }: anything starting with /_ is marked internal, meaning nginx will return 404 if a visitor requests it directly. This is what protects _includes/ and _errors/ from being served as standalone pages.
  • try_files $uri $uri.html $uri/index.html =404: this is what gives you clean URLs. A request to /about looks first for a file at /about, then for /about.html, then for /about/index.html, before returning 404. So you can write a file as about.html and link to it as /about. The comment above it (“Make /up look first for /up.html”) explains why this matters for the healthcheck: Kamal hits /up, and try_files resolves it to up.html.

For most personal sites the default server.conf is all you’ll need. You’ll edit it if you want to add redirects, custom headers, or additional locations.

Step 6: Configure the deploy

config/deploy.yml tells Kamal where to ship the container. The Skiff scaffold generates this:

# Name of your site. Used to uniquely configure containers.
service: mysite

# Name of the container image.
image: peter/mysite

# Deploy to these servers.
servers:
  - 192.168.0.1

# Use GIT_URL from secrets to auto-update site via git pulls.
env:
  secret:
    - GIT_URL

# Use Kamal's local registry.
registry:
  server: localhost:5555

proxy:
  healthcheck:
    path: /up

# Use a different ssh user than root
# ssh:
#   user: app

You need to edit at least three things:

  • service: a unique name for this app. Used in container names and the registry. I name it after the domain (peterberkenbosch-nl).
  • image: the Docker image name. The owner part can be your GitHub username or any string; Skiff uses a local registry on the deploy server (see below), so it doesn’t need to match a Docker Hub account.
  • servers: your server’s IP address or hostname. Replace 192.168.0.1 with the real IP. If you have SSH key access already configured for the root user, this is all you need. If you log in as a different user, uncomment the ssh: block and set user:.

You’ll also want to add two keys to the proxy: block:

proxy:
  ssl: true
  host: mysite.com
  healthcheck:
    path: /up

proxy.ssl: true tells kamal-proxy to request a Let’s Encrypt certificate at the origin for the listed host. The cert is renewed automatically. If you’re going to put Cloudflare in front of this with Full Strict SSL, the origin cert satisfies Cloudflare’s validation, no additional configuration needed.

proxy.host is the domain that should route to this container. If you have multiple domains pointing to the same site, use hosts: (plural) with a list.

If you run multiple sites (or apps) on the same server, this host value is how kamal-proxy knows which container gets which request. Each site on the box declares its own host, and kamal-proxy routes incoming traffic by matching the Host header. This is the same mechanism that lets you run a Rails app and a static site side by side on one server, each with its own domain and TLS cert, without any manual nginx virtual-host wrangling.

registry.server: localhost:5555 uses a local Docker registry on the deploy server. This is the Skiff default and means you don’t need a Docker Hub account or pay for a private registry. The trade-off is that the image only exists on this one server, but that’s fine for a personal site.

env.secret: [GIT_URL] declares that the container needs an environment variable called GIT_URL, and that its value comes from a secret (not committed to the repo). We’ll set that secret next.

Step 7: Give the container a way to clone

The Skiff container clones your repository every 10 seconds to detect content changes. To do that, it needs a URL it can clone from. For a private repo (or to skip GitHub’s rate limits on a public one), that URL needs an embedded auth token.

The simplest recipe, if you already have the GitHub CLI installed and authenticated (gh auth login once and you’re set):

GIT_URL=https://x-access-token:$(gh auth token)@github.com/yourusername/mysite.git

Open .kamal/secrets (it already contains a GIT_URL=... placeholder line) and replace the placeholder with the line above. Kamal sources .kamal/secrets as a shell script at deploy time, so the $(gh auth token) shells out to gh, grabs your current OAuth token, and stitches it into the URL right before Kamal injects the resolved GIT_URL into the container’s environment. The token never lands on disk inside the project, and you don’t have to manage another environment variable.

The x-access-token username plus the token as the password is how GitHub authenticates tokens over HTTPS. Don’t substitute your GitHub username here, x-access-token is the literal string GitHub expects.

Because the file only contains a reference ($(gh auth token)) and not the token itself, .kamal/secrets is safe to commit. You should commit it, so anyone cloning the repo (or you on a new machine) gets the same GIT_URL recipe without manual setup. The container image still excludes it via .dockerignore; the running container only sees the resolved GIT_URL env var that Kamal injects at runtime.

When you’d want a dedicated PAT instead

The gh auth token recipe is right for a solo developer on a personal site. Two situations call for a dedicated fine-grained PAT instead:

  1. Team deploys. Multiple people ship the site and you don’t want each one’s personal gh token shipping it. Generate one shared PAT and let everyone reference the same env var.
  2. Token stability matters. gh auth token returns whatever OAuth token your local gh session currently holds. Running gh auth logout or re-authenticating with different scopes rotates it, which means the next deploy embeds the new token. If you’d rather have a long-lived, scope-limited token that you control independently, generate a fine-grained PAT:
    GIT_URL=https://x-access-token:${GITHUB_TOKEN}@github.com/yourusername/mysite.git
    

    Then export GITHUB_TOKEN=$(op read "op://Deploy/mysite/GITHUB_TOKEN") (or your equivalent) before each skiff deploy. The trade-off is exactly the predictability you wanted: a token Skiff knows nothing about gh’s session state.

For peterberkenbosch.nl I went with the gh auth token line in this repo’s .kamal/secrets.

Step 8: Point DNS at your server

In your DNS provider, create an A record pointing your domain at your server’s IP. If you’re using Cloudflare:

  • Add an A record for the apex domain (e.g. mysite.com) pointing to your server IP.
  • Add a CNAME for www pointing to the apex.
  • Both proxied (orange cloud) if you want Cloudflare’s caching and WAF; DNS-only (grey cloud) if you want a direct connection.
  • Under SSL/TLS → Overview, set the mode to Full (Strict).

If you’re not using Cloudflare, just create the A record and let kamal-proxy’s Let’s Encrypt cert handle HTTPS directly.

DNS propagation usually takes a couple of minutes but can take longer. Use dig mysite.com to confirm the record resolves to your server before continuing.

Step 9: First deploy

Initialize git, commit, and push your code to GitHub:

git init
git add .
git commit -m "Initial site"
git branch -M main
git remote add origin [email protected]:yourusername/mysite.git
git push -u origin main

Then run:

skiff deploy

On first run, this:

  • Installs Docker on the deploy server (if not already there)
  • Starts the local Docker registry on localhost:5555
  • Starts kamal-proxy
  • Builds the container image locally
  • Pushes the image to the local registry on the server
  • Runs the container
  • Configures kamal-proxy to route traffic for your domain to the container

If you get this error immediately:

ERROR (Kamal::ConfigurationError): builder: Builder arch not set

Kamal needs to know the target architecture for the container image. Add a builder section to config/deploy.yml:

builder:
  arch: amd64

Use amd64 for most cloud servers (Hetzner, DigitalOcean, Linode). Use arm64 if you’re deploying to an ARM server (AWS Graviton, Oracle Cloud A1, Hetzner CAX). This tells Kamal which platform to build the image for, which matters if your laptop and your server have different architectures (e.g. building on an Apple Silicon Mac for an x86 server).

After adding that, re-run skiff deploy. Expect it to take a minute or two on first deploy. After it completes, visit your domain in a browser. You should see the placeholder index.html Skiff scaffolded, served via HTTPS.

If something else fails, the output usually tells you what, the most common issues are SSH access (Kamal can’t reach the server) and DNS (the domain doesn’t resolve to the server yet). Fix and re-run skiff deploy.

Step 10: How updates work

Once the container is running, you almost never need to touch it directly. The serve script inside the container starts nginx in the foreground and runs a background loop that syncs from git every 10 seconds. The loop does three things:

  1. Shallow fetch: runs git fetch --depth 1 against your repo and compares the fetched HEAD to the current one. If nothing changed, it skips the rest and sleeps.
  2. Rsync site assets: if there are changes, it does git reset --hard FETCH_HEAD and rsyncs the repo into /site/, which is what nginx serves from.
  3. Sync nginx config: checks if any .conf files in config/ changed. If they did, it copies the new config, runs nginx -t to validate, and reloads nginx. If validation fails, it restores the previous config automatically.

The script is about 100 lines of bash and lives in the root of your repository as serve. It’s worth reading in full, there’s no hidden behavior.

So the update flow for content is:

# edit a file
$EDITOR public/index.html

# commit and push
git add .
git commit -m "Update home page"
git push

# wait about 10 seconds; the container has already pulled and the
# new content is live

No skiff deploy, no container rebuild, no downtime. This is the single best feature of Skiff and the reason it’s worth using over a plain Docker setup.

The exceptions, when you do need to run skiff deploy, are infrastructure changes:

  • Changes to Dockerfile
  • Changes to the serve script
  • Changes to config/deploy.yml

For changes to config/server.conf (nginx config), you don’t need to deploy either. The polling script detects nginx config changes, runs nginx -t to validate them, and reloads nginx if valid. If validation fails, it restores the previous config automatically. This is genuinely nice for experimenting with redirects or headers.

Step 11: Share headers and footers between pages with SSI

So far we have one HTML file. The moment you add a second one, you’ll hit the same problem every static site has: the <head>, the navigation, and the footer are duplicated across pages, and changing them means editing every file.

Skiff’s nginx is configured with Server-Side Includes enabled, which lets you compose HTML from partials at request time. The scaffold already created public/_includes/header.html and public/_includes/footer.html for exactly this purpose. The pattern looks like this.

Edit public/_includes/header.html to contain your shared opening markup:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title><!--# echo var="title" --></title>
  <link rel="stylesheet" href="/assets/stylesheets/site.css">
</head>
<body>

<header>
  <a href="/">My Site</a>
  <nav>
    <a href="/about">About</a>
    <a href="/writing">Writing</a>
  </nav>
</header>

Edit public/_includes/footer.html:

<footer>
  <p>&copy; 2026, Me</p>
</footer>

</body>
</html>

Then in public/index.html:

<!--# set var="title" value="My Site, Home" -->
<!--# include file="/_includes/header.html" -->

<main>
  <h1>Welcome to my site.</h1>
  <p>Hello world.</p>
</main>

<!--# include file="/_includes/footer.html" -->

The set var directive lets each page pass its own title (or any other variable) into the included partial. The include file directive pulls the partial in at request time.

When nginx serves /, it processes the SSI directives and returns a complete HTML document with the head, body, footer, and the page’s own content composed together. The visitor sees one document. You maintain three files instead of one duplicated everywhere.

This is the entire reason Skiff is worth using over GitHub Pages for a multi-page site. The runtime cost is negligible, nginx has been doing SSI for twenty years and is very good at it.

Step 12: Add a Markdown blog with about fifty lines of Ruby

This is the part I care most about. The simplest way to get comfortable, version-controlled, Markdown-driven blog posts onto a static site is not Jekyll, not Bridgetown, not Eleventy, not Hugo, all of which want to take over the entire site. It’s a short Ruby script that reads Markdown files from one folder and writes HTML files to another. The runtime stays pure nginx; the build runs locally on your laptop, once, before each commit.

Here’s the entire pipeline.

Add the dependencies

Create Gemfile in the project root:

source "https://rubygems.org"

gem "kramdown",            "~> 2.4"
gem "kramdown-parser-gfm", "~> 1.1"
gem "rouge",               "~> 4.2"

Three gems:

  • Kramdown is the Markdown parser. It’s the same one Jekyll uses under the hood, but we’ll use it directly.
  • kramdown-parser-gfm adds GitHub-flavored Markdown extensions: fenced code blocks ( ``ruby `), tables, autolinks, strikethrough.
  • Rouge does syntax highlighting for code blocks at build time. It outputs HTML with CSS classes, which means we get highlighted code without any client-side JavaScript.

Install them:

bundle install

This creates Gemfile.lock (commit it) and installs the gems. None of them have native extensions, so install is fast and works on any platform.

Add the directories

Create the source directory for posts:

mkdir -p content/writing

Sources go here as YYYY-MM-DD-some-slug.md. The date prefix is conventional; the actual date comes from the post’s frontmatter.

Create the output directory:

mkdir -p public/writing

Generated HTML goes here. The build script writes one HTML file per post plus an index.html listing all posts.

Write the build script

Create bin/build and make it executable:

mkdir -p bin
touch bin/build
chmod +x bin/build

The script is short enough to show in full. The structure is:

#!/usr/bin/env ruby

require "kramdown"
require "kramdown-parser-gfm"
require "rouge"
require "yaml"
require "date"
require "fileutils"
require "cgi"

ROOT       = File.expand_path("..", __dir__)
SOURCE_DIR = File.join(ROOT, "content", "writing")
OUTPUT_DIR = File.join(ROOT, "public", "writing")

def parse_post(path)
  raw = File.read(path, encoding: "UTF-8")
  match = raw.match(/\A---\s*\n(?<frontmatter>.*?)\n---\s*\n(?<body>.*)\z/m)
  abort "✗ #{path}: missing YAML frontmatter" unless match

  meta = YAML.safe_load(match[:frontmatter], permitted_classes: [Date])
  meta["slug"] ||= File.basename(path, ".md").sub(/\A\d{4}-\d{2}-\d{2}-/, "")
  meta["date"] = Date.parse(meta["date"].to_s)
  meta["body"] = match[:body]
  meta
end

def render_markdown(body)
  Kramdown::Document.new(
    body,
    input: "GFM",
    syntax_highlighter: "rouge",
    syntax_highlighter_opts: { css_class: "highlight" }
  ).to_html
end

def h(str)
  CGI.escapeHTML(str.to_s)
end

def render_post_page(meta)
  body_html = render_markdown(meta["body"])
  date_human = meta["date"].strftime("%-d %B %Y")

  <<~HTML
    <!--# set var="title" value="#{h(meta["title"])}" -->
    <!--# include file="/_includes/header.html" -->

    <main>
      <article>
        <p><time datetime="#{meta["date"].iso8601}">#{date_human}</time></p>
        <h1>#{h(meta["title"])}</h1>
        #{body_html}
      </article>
      <p><a href="/writing">← All writing</a></p>
    </main>

    <!--# include file="/_includes/footer.html" -->
  HTML
end

def render_index_page(posts)
  rows = posts.map do |meta|
    date_short = meta["date"].strftime("%-d %b %Y")
    %Q(
      <article>
        <time datetime="#{meta["date"].iso8601}">#{date_short}</time>
        <h2><a href="/writing/#{meta["slug"]}">#{h(meta["title"])}</a></h2>
        <p>#{h(meta["summary"])}</p>
      </article>
    )
  end.join

  <<~HTML
    <!--# set var="title" value="Writing" -->
    <!--# include file="/_includes/header.html" -->

    <main>
      <h1>Writing</h1>
      #{rows}
    </main>

    <!--# include file="/_includes/footer.html" -->
  HTML
end

# Main
FileUtils.mkdir_p(OUTPUT_DIR)

posts = Dir.glob(File.join(SOURCE_DIR, "*.md"))
  .map { |path| parse_post(path) }
  .reject { |meta| meta["draft"] }
  .sort_by { |meta| meta["date"] }
  .reverse

posts.each do |meta|
  out = File.join(OUTPUT_DIR, "#{meta["slug"]}.html")
  File.write(out, render_post_page(meta))
  puts "  ✓ #{meta["slug"]}.html"
end

File.write(File.join(OUTPUT_DIR, "index.html"), render_index_page(posts))
puts "  ✓ index.html (#{posts.length} posts)"

Reading top to bottom: the script requires its dependencies, defines where source and output directories live, and provides four helpers:

  • parse_post opens a Markdown file, splits it into YAML frontmatter and body, derives the URL slug from the filename, and returns a hash with everything the templates need.
  • render_markdown converts a string of Markdown to a string of HTML, using GitHub-flavored Markdown for fenced code blocks and Rouge for syntax highlighting.
  • h is HTML-escape, anything from user-controlled input goes through this before being interpolated into HTML, to avoid breaking pages when post titles contain < or &.
  • render_post_page and render_index_page are the two templates: one for an individual post, one for the index. They produce strings of HTML that use SSI to include the shared head and footer.

The main block at the bottom is the actual work: find all the Markdown files, parse them, skip drafts, sort newest-first, write one HTML file per post, write the index.

That’s the entire blog. About 80 lines of Ruby, three gems, no configuration file.

Write a post

Create your first post at content/writing/2026-05-21-hello-world.md:

(I’m using ~~~ instead of triple-backticks to delimit the example below, because the example itself contains a triple-backtick code block, and same-character nested fences close the outer one early. For your real posts, triple-backticks are fine, you only need this workaround when a code block contains another code block.)

---
title: Hello world
date: 2026-05-21
summary: My first post on the new setup.
---

This is a Markdown post.

## A subheading

Some prose with **bold** and *italic* and a `code` snippet.

```ruby
puts "hello"
```

A list:

- one
- two
- three

The frontmatter at the top is YAML between two --- lines. title, date, and summary are required by the templates. Optional fields are slug (override the auto-derived URL slug) and draft (set to true to exclude from the build). Posts are published by default. If you don’t include draft in the frontmatter, the post is built and published. Only when you explicitly add draft: true is it excluded.

Build it

bin/build

Expected output:

  ✓ hello-world.html
  ✓ index.html (1 posts)

Now public/writing/ contains two HTML files: the post and the index. Open them in a browser locally, or run skiff dev to serve the whole site with SSI processing.

Commit and push

git add content/writing/ public/writing/
git commit -m "Post: hello world"
git push

Within about 10 seconds, the post is live at https://yourdomain.com/writing/hello-world. The index page at /writing lists it. The shared header, nav, and footer come along automatically via SSI.

You’ll notice that both the Markdown source and the generated HTML are committed. This is deliberate. The deploy pipeline only knows how to serve static files, there’s no Ruby in the container. Committing the generated HTML means the deploy stays radically simple. The small amount of repository noise is, in my opinion, worth it. (You can also exclude public/writing/*.html from git and run the build in a CI step, but that’s significantly more moving parts for a personal site.)

Wrapping up

What you have now:

  • A static site running in your own infrastructure, behind your own domain, with TLS.
  • A deploy model where content changes are git push and propagate in about 10 seconds.
  • A clean separation between content (public/) and infrastructure (config/, Dockerfile, serve).
  • Shared headers and footers via SSI partials, change the nav once, it updates everywhere.
  • A Markdown blog you can write in your editor, with syntax-highlighted code blocks, that publishes via bin/build && git push.

The total runtime is nginx serving static files. There’s no database, no application server, no asset pipeline, no build cache. The whole container is a few megabytes. The whole repository for my own site, including this post, is well under a hundred kilobytes.

A few things to skip if you’re starting fresh

The setup as I’ve described it has a few pieces that are useful but not essential. If you’re just starting:

  • The blog script is overkill if you don’t plan to write much. A single hand-edited writing.html file is a perfectly reasonable starting point. Add the script when you’re tired of copying and pasting the boilerplate for the third time.
  • The SSI partials are worth doing from day one. They cost almost nothing to set up and they save you the first time you change the footer.
  • You can skip Cloudflare entirely. kamal-proxy with Let’s Encrypt handles TLS at the origin perfectly well. Add Cloudflare later if you start needing the caching, the DDoS protection, or just want a CDN in front for latency.
  • You don’t need a staging environment. The scaffold includes config/deploy.staging.yml for it, but for a personal site, pushing small changes directly to production is fine. Most of what you’ll break is recoverable in 30 seconds.