- Sun 14 December 2025
- FreeBSD
- #freebsd, #jails, #deployment, #caddy, #ci-cd, #bastille, #nginx, #infrastructure
Self-hosting a blog might seem like overkill in the age of managed platforms, but there’s something deeply satisfying about controlling your entire stack. This article walks through how this very blog is hosted: a FreeBSD 15.0 server running multiple Bastille jails, with automated deployments triggered by git pushes to a self-hosted Forgejo instance. The setup prioritizes security through isolation, simplicity through static site generation, and reliability through automation.

Architecture Overview
Before diving into the details, here’s how the pieces fit together:
Internet
|
v
+-----------------------+
| PF Firewall |
| NAT + RDR |
+-----------+-----------+
|
+------------+------------+
| |
v v
+-------------+ +-------------+
| Caddy Jail | | Transporter |
| TLS + Proxy | | Jail |
+------+------+ | rsync only |
| +------+------+
v ^
+-------------+ |
| Blog Jail | nullfs mount |
| Nginx +------------------+
+-------------+ |
(rsync over SSH)
|
+--------+--------+
| Forgejo Runner |
| (remote server) |
+-----------------+
Traffic flows from the internet through PF’s packet filter, gets redirected to the Caddy jail for TLS termination, then proxied to the blog jail where Nginx serves static files. Deployments come in separately: a Forgejo runner builds the site and rsyncs it to the transporter jail, which has the blog’s webroot mounted via nullfs.
The Forgejo Git-Forge installation is not covered in this article. This might be a topic for a future post :-)
The Host System
The server runs FreeBSD 15.0-RELEASE on a VPS with dual-stack networking. Here’s the relevant portion of /etc/rc.conf:
hostname="server.example.com"
# Security hardening
kern_securelevel_enable="YES"
kern_securelevel="2"
# Network setup - dual stack
ifconfig_vtnet0="inet 192.0.2.10 netmask 255.255.252.0 -lro -tso"
ifconfig_vtnet0_ipv6="inet6 2001:db8::2 prefixlen 68"
defaultrouter="192.0.2.1"
ipv6_defaultrouter="fe80::1%vtnet0"
# Bastille jail network bridge
cloned_interfaces="bridge0"
ifconfig_bridge0_name="bastille0"
ifconfig_bastille0="inet 10.254.254.1/24"
ifconfig_bastille0_ipv6="inet6 2001:db8:1000::1 prefixlen 68"
gateway_enable="YES"
ipv6_gateway_enable="YES"
# Services
pf_enable="YES"
bastille_enable="YES"
zfs_enable="YES"
Setting kern_securelevel="2" is a simple but effective hardening measure. At securelevel 2, the system prevents loading kernel modules, writing to mounted filesystems marked immutable, and modifying the firewall rules without a reboot. This means even if an attacker gains root access, they can’t disable PF or load a rootkit module.
The Bastille bridge network (bastille0) provides a private network for all jails. Each jail gets an IP from the 10.254.254.0/24 range for IPv4 and a corresponding address in the 2001:db8:1000::/68 range for IPv6.
Jail Architecture
Bastille manages three jails relevant to this setup:
$ bastille list all
JID State IP Address Hostname Release
1 Up 10.254.254.20 blog 15.0-RELEASE
2001:db8:1000::20
2 Up 10.254.254.10 caddy 15.0-RELEASE
2001:db8:1000::10
3 Up 10.254.254.25 transporter 15.0-RELEASE
2001:db8:1000::25
The Caddy Jail
This jail handles all incoming HTTPS traffic. Caddy automatically obtains and renews Let’s Encrypt certificates, terminates TLS, and proxies requests to the appropriate backend. It’s the only jail that needs to be reachable from the internet on ports 80 and 443.
The Blog Jail
A minimal jail running Nginx to serve static files. The webroot at /usr/local/www/ contains the generated Pelican output. The Nginx configuration is intentionally vanilla - just a basic server block pointing at the webroot with no special tuning required for static content. This jail has no public network exposure - all traffic comes through Caddy’s reverse proxy.
The Transporter Jail
This is the clever part. The transporter jail exists solely to receive deployments. It runs SSH but with heavily restricted access: only rsync is allowed, only from the Forgejo runner’s IP, and only to a specific directory. The magic happens through nullfs mounts:
# /usr/local/bastille/jails/transporter/fstab
/usr/local/bastille/jails/blog/root/usr/local/www/blog.example.com \
/usr/local/bastille/jails/transporter/root/usr/local/deploy/blog nullfs 0 0
/usr/local/bastille/jails/blog/root/usr/local/www/staging.example.com \
/usr/local/bastille/jails/transporter/root/usr/local/deploy/stageblog nullfs 0 0
When the CI runner rsyncs files to /usr/local/deploy/blog/ inside the transporter jail, they’re actually being written directly to the blog jail’s webroot. The transporter never stores anything - it’s just a secure gateway.
PF Firewall Configuration
PF ties everything together with NAT for outbound jail traffic and redirects for inbound services:
# --- Macros ---
ext_if = "vtnet0"
jail_net = "10.254.254.0/24"
jail_net6 = "2001:db8:1000::/68"
host_ipv6 = "2001:db8::2"
frontend_v4 = "10.254.254.10"
frontend_v6 = "2001:db8:1000::10"
transport_v4 = "10.254.254.25"
transport_v6 = "2001:db8:1000::25"
forgejo_runner_v4 = "198.51.100.50"
# --- Tables ---
table <bruteforce> persist
table <jails_v4> { $jail_net }
table <jails_v6> { $jail_net6 }
# --- Options ---
set skip on lo0
set block-policy drop
# --- NAT for jail egress ---
nat on $ext_if inet from <jails_v4> to any -> ($ext_if)
nat on $ext_if inet6 from <jails_v6> to any -> $host_ipv6
# --- RDR for services ---
rdr pass on $ext_if inet proto tcp to ($ext_if) port {80,443} -> $frontend_v4
rdr pass on $ext_if inet6 proto tcp to $host_ipv6 port {80,443} -> $frontend_v6
# Deployment SSH - only from Forgejo runner
rdr pass on $ext_if inet proto tcp from $forgejo_runner_v4 \
to ($ext_if) port 2225 -> $transport_v4 port 22
# --- Filtering ---
block quick from <bruteforce>
block drop in log all
block drop out log all
pass out quick all keep state
antispoof quick for { $ext_if, bastille0 }
# Essential ICMP
pass in inet proto icmp icmp-type { echoreq, unreach }
pass in quick inet6 proto ipv6-icmp icmp6-type { echoreq, echorep, neighbrsol, neighbradv }
# Jail egress
pass in quick on bastille0 from <jails_v4> to ! 10.254.254.0/24 keep state
pass in quick on bastille0 inet6 from <jails_v6> to ! 2001:db8:1000::/68 keep state
A few things worth noting:
- Default deny: Everything is blocked unless explicitly allowed
- Deployment SSH on port 2225: The transporter jail’s SSH is only reachable from the Forgejo runner’s IP address. Anyone else hitting that port gets silently dropped.
- Brute-force protection: The
<bruteforce>table can be populated by rules elsewhere (e.g., for the host’s SSH) to automatically block repeat offenders - NAT for jails: Jails can reach the internet for package updates and certificate validation, but they appear to come from the host’s public IP
The Deployment Pipeline
The deployment relies on an SSH keypair generated beforehand: the public key lives in the transporter jail’s authorized_keys (with the rrsync restriction shown later), while the private key is stored as a secret in Forgejo. When I push to the blog’s git repository, a Forgejo Actions workflow kicks off:
on:
push:
branches: [ '**' ]
jobs:
deploy:
runs-on: docker
container: python:3.11-slim
steps:
- name: Install Dependencies
run: |
apt-get update
apt-get install -y rsync openssh-client git
pip install pelican pelican-sitemap markdown typogrify
- name: Checkout Code
uses: actions/checkout@v3
with:
submodules: recursive
- name: Configure Target
id: meta
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "url=https://blog.example.com" >> $GITHUB_OUTPUT
echo "dest=blog/" >> $GITHUB_OUTPUT
else
echo "url=https://staging.example.com/$SHORT_SHA" >> $GITHUB_OUTPUT
echo "dest=stageblog/$SHORT_SHA/" >> $GITHUB_OUTPUT
fi
- name: Build Site
env:
SITEURL: ${{ steps.meta.outputs.url }}
run: pelican content -o output -s publishconf.py
- name: Deploy via Rsync
run: |
rsync -avz --delete -e "ssh -p 2225" \
output/ deploy@server.example.com:${{ steps.meta.outputs.dest }}
The branch strategy is simple: pushes to main deploy to production, pushes to any other branch deploy to a staging URL with the commit hash in the path. This lets me preview changes before merging.
The transporter jail’s authorized_keys file locks down what the deploy user can do:
command="/usr/local/sbin/rrsync -wo /usr/local/deploy/",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding ssh-ed25519 AAAA... deploy@runner
The rrsync wrapper (restricted rsync) ensures the SSH key can only write to /usr/local/deploy/ and nothing else. Combined with the PF rules limiting source IPs, this creates a minimal attack surface for deployments.
Caddy Configuration
Caddy’s configuration is refreshingly simple. Here’s the production site block:
blog.example.com {
reverse_proxy 10.254.254.20:80 # blog jail
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/blog.access.log {
roll_size 100mb
roll_keep 10
}
format json
}
}
staging.example.com {
reverse_proxy 10.254.254.20:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
Caddy handles certificate management automatically via ACME. The security headers provide defense-in-depth: HSTS ensures browsers always use HTTPS, X-Frame-Options prevents clickjacking, and the other headers mitigate various web attacks. For a static blog these might seem excessive, but they cost nothing and establish good habits.
Conclusion
This setup might look complex at first glance, but each piece serves a purpose:
- Bastille jails provide lightweight isolation without the overhead of full VMs
- PF gives fine-grained control over traffic flow and enforces the principle of least privilege
- The transporter pattern allows secure deployments without exposing the blog jail directly
- Forgejo Actions automates the entire build-and-deploy cycle
- Caddy simplifies TLS and reverse proxying to a few lines of config
The result is a blog that deploys automatically on every git push, runs in isolated jails with minimal attack surface, and costs just a few euros per month on a small VPS. FreeBSD’s jails remain one of the most elegant isolation mechanisms available - simpler than containers, more lightweight than VMs, and battle-tested over two decades.
If you’re considering self-hosting, I’d encourage giving FreeBSD and Bastille a try. The learning curve is worth it.
Further Reading
- FreeBSD Handbook: Jails
- Bastille documentation
- Caddy web server
- Pelican static site generator
- Forgejo Actions
- PF - The OpenBSD Packet Filter
Thanks to everyone behind FreeBSD, Bastille, Caddy, Pelican, Forgejo, and the countless other open source projects that make setups like this possible. Standing on the shoulders of giants has never been easier.