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:
- Check out the repo.
- Set up Hugo, as that’s what my blog is built with.
- Build the site using
hugo --minify. - Join the tailnet using Tailscale’s official GitHub Action.
rsyncthe 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:
TS_AUTHKEY- the pre-auth key for the Tailscale node, which you created above.TS_LOGIN_SERVER- the Headscale login server (which you can omit if you are using Tailscale).DEPLOY_HOST- the tailnet IP address of the node you are deploying to.
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.