blog.thms.uk

Moving my blog to self-hosted, powered by Forgejo

When I first started this blog I was on the omg.lol platform.

This was a great way for me to get started, and I think without it, I probably would’ve never started my blog, so I’m really grateful for omg.lol!

But over the last few months I’ve gradually moved more and more of my stuff to self-hosted, and with omg.lol more than doubling their price (which, to be fair, is still excellent value!) it was time to move my blog to self-hosted too.

This is how I now host my static blog at blog.thms.uk, deployed straight from a Forgejo repo via Forgejo Actions, over a Tailscale/Headscale tailnet, onto a Caddy reverse proxy, auto-deploying when I push to main.

Set up the host machine

First job is to create a home for the site and a user to own it. The site will be hosted on my existing server that already has Caddy installed. We’ll create a deploy user that owns /var/www so the rsync at the end has somewhere to write to. On the web server, create the user and directory:

sudo useradd -m deploy
sudo mkdir /var/www
sudo chown deploy:deploy /var/www

Set up Headscale

The plan here is to let the CI runner reach the web server over the tailnet and nothing else. We do that with tags: the runner joins as tag:ci and the web server is tag:web. No keys, no exposed ports on the public internet.

I’m running my tailnet through a self-hosted Headscale instance via Docker Compose, so that’s what the rest of this section focuses on. If you’re using Tailscale you’ll have to do essentially the same steps in the Tailscale dashboard.

First, define the tags and permissions in the policy file:

{
    "tagOwners": {
      "tag:ci":  ["<your-user>"],
      "tag:web": ["<your-user>"]
    },
    "acls": [
      { "action": "accept", "src": ["tag:ci"], "dst": ["tag:web:22"] }
    ],
    "ssh": [
      { "action": "accept", "src": ["tag:ci"], "dst": ["tag:web"], "users": ["deploy"] }
    ]
}

You need both acl and ssh rules because they work at different layers: the acls entry is the network firewall, while the ssh entry is what then authorises the actual SSH session.

Then restart headscale to apply the change:

docker compose up -d --force-recreate headscale

Now, assign the tag:web tag to the web server (<id> is the ID of the web server - you can find it with docker exec headscale headscale nodes list):

docker exec headscale headscale nodes tag -i <id> -t tag:web

Finally, we need a pre-auth key for the runner to authenticate with. We’ll create an ephemeral key so the node disappears from the tailnet when the job finishes (no pile-up of dead CI nodes), and mark it reusable so every run can use the same secret. We also add a long expiry to save us rotating it constantly - 876,000 hours is roughly 100 years, so dial that back if it makes you nervous:

docker exec headscale headscale preauthkeys create --ephemeral --reusable --tags tag:ci -e 876000h

This key will allow the deployer CI to join the tailnet as a machine tagged with tag:ci.

Finally, if it isn’t already on, enable Tailscale SSH on the web server:

tailscale set --ssh

Create your deployment script

With the tailnet sorted, it’s finally time to move on to the most important part of this post: the deploy workflow. We’ll create it in .forgejo/workflows/deploy.yml.

Deploying the site, for me, means:

  1. Check out the repo.
  2. Set up Hugo, as that’s what my blog is built with.
  3. Build the site using hugo --minify.
  4. Join the tailnet using Tailscale’s official GitHub Action.
  5. rsync the site to the web server node.

Obviously steps 2 and 3 may well be different for your site, so adjust them accordingly.

It’s worth noting that step 3 produces the final static files for the blog in ./public, and that’s what we’ll be rsyncing in the final step. If your build step produces its files in a different location (or you aren’t using any build steps at all) you’ll need to change the rsync line at the end.

The comments in the file spell out each part, along with some reasoning behind each step:

on:
  workflow_dispatch:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest  # replace with whatever runner you have available.
    name: Deploy site
    steps:
      # 1. Check out the repo.
      - uses: https://github.com/actions/checkout@v4
        with:
          fetch-depth: 0

      # 2. Set up Hugo
      - name: Setup Hugo (extended)
        uses: https://github.com/peaceiris/actions-hugo@v3
        with:
          hugo-version: latest
          extended: true
          
      # 3. Build the site
      - name: Build site
        run: hugo --minify
        shell: bash

      # 4. Join the tailnet (Headscale) as an ephemeral, tagged node (tag:ci) via the
      #    official action. authkey + login-server points it at Headscale; userspace
      #    networking + a local SOCKS5 proxy means no TUN/privileges in the container.
      - name: Connect to Tailnet
        uses: https://github.com/tailscale/github-action@v3
        with:
          authkey: ${{ secrets.TS_AUTHKEY }}                  # Headscale pre-auth key (reusable, ephemeral)
          tailscaled-args: --tun=userspace-networking --socks5-server=localhost:1055
          args: --login-server=${{ secrets.TS_LOGIN_SERVER }} # Drop this if you are using Tailscale rather than Headscale.

      # 5. Publish ./public to the remote node over Tailscale SSH. ssh/rsync tunnel through
      #    the SOCKS5 proxy the step above started.
      - name: Publish to Caddy
        env:
          SITE: blog.thms.uk                        # replace with your site name
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}   # remote node tailnet IP (100.x.y.z)
          DEPLOY_USER: deploy                       # Unix user on the node (Tailscale SSH login)
        run: |
          set -e
          # Ensure we have all the tools we need: rsync, OpenSSH client, netcat for the SOCKS proxy
          pkgs=                                                                                                
          command -v rsync >/dev/null 2>&1 || pkgs="$pkgs rsync"                                               
          command -v ssh   >/dev/null 2>&1 || pkgs="$pkgs openssh-client"                                      
          command -v nc    >/dev/null 2>&1 || pkgs="$pkgs netcat-openbsd"                                      
          [ -n "$pkgs" ] && { apt-get update -qq && apt-get install -y -qq $pkgs; }
          # Set up `~/.ssh/config` to configure the SSH tunnel and other SSH client settings
          install -d -m 700 ~/.ssh
          printf 'Host deploy-target\n  HostName %s\n  User %s\n  ProxyCommand nc -X 5 -x localhost:1055 %%h %%p\n  StrictHostKeyChecking accept-new\n  ConnectTimeout 20\n  BatchMode yes\n' \
            "$DEPLOY_HOST" "$DEPLOY_USER" > ~/.ssh/config
          chmod 600 ~/.ssh/config
          # create the required directory on the remote node
          ssh deploy-target "mkdir -p /var/www/$SITE"
          # rsync the site to the remote node
          rsync -az --delete -e ssh public/ "deploy-target:/var/www/$SITE/"
        shell: bash

The script requires three secrets, so add the following Action Secrets to the repository:

Configure Caddy

The final step is getting Caddy to actually serve the files. I have Caddy running in a container, so it can’t see /var/www on the host until we mount it in - which we’ll do read-only, since Caddy only ever needs to read the site. Add the bind mount to the Caddy service:

services:
  caddy:
    # [...]
    volumes:
      - /var/www:/var/www:ro
      # other bind mounts

Then tell Caddy about the site. This is the whole config for a static site - point root at the deploy directory, turn on compression, and hand it to the file server:

blog.thms.uk {
  root * /var/www/blog.thms.uk
  encode zstd gzip
  file_server
  
  # This part is for pretty 404 pages, and assumes you have a 404 page at /404.html
  handle_errors {
    @404 expression {http.error.status_code} == 404
    rewrite @404 /404.html
    file_server
  }
}

The bind mount is a change to the container itself, so this one time you do need to recreate it:

docker compose up -d --force-recreate caddy

After that, adding new sites is much cheaper: The volume’s already mounted, so it’s just another block in the Caddyfile followed by a config reload - no restart, no dropped connections:

docker exec caddy caddy reload --config /etc/caddy/Caddyfile

Wrapping up

That’s the lot. Push to main and the site builds, joins the tailnet as an ephemeral tagged node, rsyncs ./public to the remote over Tailscale SSH, and Caddy serves it. Adding more sites later is just another block in the Caddyfile and a config reload.