Continuous deployment for this site on FreeBSD

Introduction

I run this website on a smaller VPS out of the Netherlands and because I don’t have a development machine for FreeBSD at the moment, and I have not set up cross compilation I had to compile it on the VPS. That was rather slow and made the site rather annoying to update. The site was also just running in a instance of Zellij which made it annoying when I had to reboot or something since I then had to find all the commands to run the site again. So this post will walk through 2 things, setting up a service on FreeBSD, and setting up continuous deployment from Sourcehut. As always comments are welcome on www@erk.dev.

Setting up a service script

I started on UNIX around the time where systemd started to gain foothold and be the default so I have never really had to write proper service scripts since I have just been using the service files from systemd. So I had to look a bit around to find some good documentation. Which FreeBSD luckily has in general.

My main sources for how to get it running was the FreeBSD handbook, and The practical guide to rc.d scripting in BSD. The manpage for daemon(8) was also quite helpful.

The script

#!/bin/sh
# /usr/local/etc/rc.d/hs

# PROVIDE: hs
# REQUIRE: LOGIN
# KEYWORD: shutdown

My script starts with a #! telling the runner that it should use shell, then a comment to myself telling me where I can expect the file to exist on my system, it then has a few statements, they say what this script provide and what its requirements are.

. /etc/rc.subr

name=hs
rcvar=hs_enable

load_rc_config $name

First I load some commands described in rc.subr(8) which adds various utilities.

Then I define the name of the service and the variable to enable it. Then I use one of the scripts I sourced to source hs variables in the rc config.

: ${hs_enable="NO"}
: ${hs_home_dir:="/usr/local/www"}
: ${hs_port:="8080"}
: ${hs_log:="INFO"}
# Allow service jail
: ${hs_svcj_options:="net_basic"}

hs_env="${hs_env} HS_PORT=${hs_port} RUST_LOG=${hs_log}"

Then I have a list of various options that you can change the most interesting one here is hs_svcj_options which tells the init what options it should use for the service jail[^1] if that is used. Finally there is a line defining the environment variables that will be passed on, hs_env is a variable defined by rc.subr.

pidfile="/var/run/${name}.pid"
procname=/usr/local/bin/hs
command=/usr/sbin/daemon
command_args="-S -f -p ${pidfile} -u www ${procname} --home=${hs_home_dir} --logfile=default"

run_rc_command "$1"

Finally we set everything in motion with daemon and run_rc_command.

Full source of the service script
#!/bin/sh
# /usr/local/etc/rc.d/hs

# PROVIDE: hs
# REQUIRE: LOGIN
# KEYWORD: shutdown

. /etc/rc.subr

name=hs
rcvar=hs_enable

load_rc_config $name

: ${hs_enable="NO"}
: ${hs_home_dir:="/usr/local/www"}
: ${hs_port:="8080"}
: ${hs_log:="INFO"}
# Allow service jail
: ${hs_svcj_options:="net_basic"}

hs_env="${hs_env} HS_PORT=${hs_port} RUST_LOG=${hs_log}"

pidfile="/var/run/${name}.pid"
procname=/usr/local/bin/hs
command=/usr/sbin/daemon
command_args="-S -f -p ${pidfile} -u www ${procname} --home=${hs_home_dir} --logfile=default"

run_rc_command "$1"

Then we can enable it by running service hs enable and service hs
start
. This will start it as a daemon and dump any logs into /var/log/daemon.log.

Creating a FreeBSD package

I wanted to only install the binary on the server so I would have to build it elsewhere and then upload it somehow, I could have the binary and service file as separate things, but I would rather have it in one part I could update atomically.

At work I have been creating Debian packages to distribute our own software so I thought to do something similar and use PKG files.

The documentation for making your own crates outside of ports is not super well documented (at least not anywhere I looked), it uses a super-set of json that is different from YAML called UCL. And the keys you need are only slightly documented, and I wanted something minimal.

If you need somewhere to start it is possible to dump a packages manifest file with pkg info -R <pkgname>.

I found a example setup for a build on GitHub, which I used as base for my own setup, it uses a “legacy” format for files so I might have to redo it at some point.

A future idea is to make something like cargo deb, but for FreeBSD.

I use two files for the build a manifest.in file containing some templateing for the package manifest and a Makefile to build it all.

The manifest.in does not contain a lot, just enough to get by:

name: hs
version: %%VERSION%%
origin: erk/hs
comment: Erk's homepage
www: https://git.sr.ht/~erk/hs
maintainer: www@erk.dev
prefix: %%PREFIX%%
desc: Erk's homepage

Just various information about the crate, the %%VAR%% strings are variables to be changed when creating the final manifest.

The Makefile has in a way two parts, building the binary and building the package.

VERSION != date +"%Y.%m.%d"
PKG = hs.pkg
PREFIX= /usr/local

.PHONY: all clean clean-stage ../target/release/hs

all: ${PKG}
clean: clean-stage
cargo clean
rm -f ${PKG} pkg-plist manifest
clean-stage:
rm -rf stage

../target/release/hs:
npm install
cargo build --release

It is not a complicated file to build the binary, the only special thing I do here is setting the version to the current date, I always run the npm and cargo steps since they have internal tracking of if it needs to be rebuilt. So I don’t have to list all source files here.

${PKG}: clean-stage ../target/release/hs service.sh manifest.in
mkdir -p stage${PREFIX}/bin stage${PREFIX}/etc/rc.d
cp ../target/release/hs stage${PREFIX}/bin/
cp service.sh stage${PREFIX}/etc/rc.d/hs
cat manifest.in | sed -e 's|%%VERSION%%|${VERSION}|' | sed -e 's|%%PREFIX%%|${PREFIX}|' > manifest
echo ${:! find stage -type f !:C/^stage//} | tr ' ' '\n' > pkg-plist
pkg create -M manifest -r stage -p pkg-plist

Here is the interesting step. First I make a staging dir where we can put the file under the prefix they need to be put. then I make the directories and copy the files in.

I then create the manifest file from the template putting in the version and prefix. I then use the legacy pkg-plist file to give a list of all files in the staging dir.

/usr/local/bin/hs
/usr/local/etc/rc.d/hs

All this then gets put together with a pkg command

$ pkg create -M manifest -r stage -p pkg-plist

And then you have a hs-2026-04-21.pkg file.

Full source of the Makefile
VERSION != date +"%Y.%m.%d"
PKG = hs.pkg
PREFIX= /usr/local

.PHONY: all clean clean-stage ../target/release/hs

all: ${PKG}
clean: clean-stage
cargo clean
rm -f ${PKG} pkg-plist manifest
clean-stage:
rm -rf stage

../target/release/hs:
npm install
npm update
cargo build --release

${PKG}: clean-stage ../target/release/hs service.sh manifest.in
mkdir -p stage${PREFIX}/bin stage${PREFIX}/etc/rc.d
cp ../target/release/hs stage${PREFIX}/bin/
cp service.sh stage${PREFIX}/etc/rc.d/hs
cat manifest.in | sed -e 's|%%VERSION%%|${VERSION}|' | sed -e 's|%%PREFIX%%|${PREFIX}|' > manifest
echo ${:! find stage -type f !:C/^stage//} | tr ' ' '\n' > pkg-plist
pkg create -M manifest -r stage -p pkg-plist

Continuous

This sites source code is hosted on Sourcehut so I am using its CI/CD tooling for this project.

Similar to other code forges such as GitHub and GitLab it uses a Yaml file for configuring the continuous integration. Although compared to the other code forges I named it is quite a bit more simple. The manual for the build system is here, and the documentation for the Yaml file is here.

My configuration lives in a file called .build.yml

# Only publish when pushing to main.
submitter:
git.sr.ht:
enabled: true
allow-refs:
- refs/heads/main
- "refs/tags/*"

First I define that it should only run when pushed to main (or tags) so pushes to a separate branch will not publish to my site.

image: freebsd/latest
packages:
- npm
- pkg
- rustup-init
- gurl
sources:
- https://git.sr.ht/~erk/hs
secrets:
- 14a751f8-83a4-4dd7-9ed3-3d12aa32d9a8

Then I define I want to use freebsd/latest with various packages, the git repository my code lives in and a secret. In theory you can add multiple sources even some not hosted on Sourcehut which I think is pretty cool.

tasks:
- setup-rust: |
rustup-init -y --default-toolchain stable --profile minimal
. "$HOME/.cargo/env"
echo 'PATH="~/.cargo/bin:$PATH"' >> ~/.buildenv
- build: |
. "$HOME/.cargo/env"
cd hs/freebsd
make
mv "hs-$(date +'%Y.%m.%d').pkg" "hs.pkg"
- deploy: |
# Unset x to not leak secrets.
set +x
export HMAC_SECRET="sha256:x-hmac-sig:$(cat ~/secret_a.txt)"
set -x
gurl -json=true -hmac HMAC_SECRET \
POST https://hook.erk.dev/hooks/srht-deploy \
job_id="$JOB_ID"

Then I define a small series of tasks, first one setting up Rust, then I build my package with the Makefile from above, and then I “deploy” it by sending a signed message to my own server.

artifacts:
- "hs/freebsd/hs.pkg"

Finally I list a file I want uploaded as a artifact after the run is complete.

Full source of .build.yml
# Only publish when pushing to main.
submitter:
git.sr.ht:
enabled: true
allow-refs:
- refs/heads/main
- "refs/tags/*"
image: freebsd/latest
packages:
- npm
- pkg
- rustup-init
- gurl
sources:
- https://git.sr.ht/~erk/hs
secrets:
- 14a751f8-83a4-4dd7-9ed3-3d12aa32d9a8
tasks:
- setup-rust: |
rustup-init -y --default-toolchain stable --profile minimal
. "$HOME/.cargo/env"
echo 'PATH="~/.cargo/bin:$PATH"' >> ~/.buildenv
- build: |
. "$HOME/.cargo/env"
cd hs/freebsd
make
mv "hs-$(date +'%Y.%m.%d').pkg" "hs.pkg"
- deploy: |
# Unset x to not leak secrets.
set +x
export HMAC_SECRET="sha256:x-hmac-sig:$(cat ~/secret_a.txt)"
set -x
gurl -json=true -hmac HMAC_SECRET \
POST https://hook.erk.dev/hooks/srht-deploy \
job_id="$JOB_ID"
artifacts:
- "hs/freebsd/hs.pkg"

Deployment

My deployment on the server site is a bit homemade, it is split into two parts, first a webhook server that receives the message sent above, it then starts the second part which is a small simple script.

Webhooks

My webhook configuration is close to verbatim copied from a article from the FreeBSD journal called Kick Me Now With Webhooks which talks about using the webhook webhook server to receive and check signed messages and possible start commands.

It also uses Yaml for configuration:

- id: srht-deploy
execute-command: "/path/to/deploy-hs.sh"
command-working-directory: "/home/noone"
pass-arguments-to-command:
- source: payload
name: job_id
trigger-rule:
and:
- match:
type: payload-hmac-sha256
secret: 31337 # secret a
parameter:
source: header
name: x-hmac-sig

It sends the JOB_ID of the build job further on into a small script, which will install the new package and restart the server.

A annoying part I have not really resolved yet is that it has to run the script as root which I am not a big fan of, if anyone have an idea to not need that let me know.

Install

The install script will try to fetch the package from Sourcehut and installs it.

#!/bin/sh

JOB_ID=$1

set -xe

i=0
while [ $i -le 10 ]; do
echo "Build check $i"
HUT_POLL="$(/usr/local/bin/sudo -u erk hut builds artifacts "$JOB_ID")"
if [ -z "$HUT_POLL" ]; then
sleep 10
i=$(( i + 1 ))
continue
fi
ARTIFACT_URL="$(echo "$HUT_POLL" | awk '{ print $4 }')"
fetch -o hs.pkg "$ARTIFACT_URL"
pkg install -y "./hs.pkg"
rm "./hs.pkg"
service hs restart
exit 0
done

The script will try 10 times with a 10 second sleep in-between to fetch and install the package. Currently it is a bit larger than it needs to since pkg recently added support for installing directly from a URL so I can cut it down a bit in the future.

As can be seen here you can install arbitrary packages on the machine so its a very good idea to sign your webhook payloads.

Conclusion

All in all I am quite happy with my new setup, at least it is a lot better than my previous setup.

I am a bit unhappy with the webhook setup, but currently I have not figured out a better Idea so I would love to hear from any who have done something similar to this.

Thanks for reading this post :D

Footnotes

  1. A service jail is a very thin jail that runs a single service, it does not give as much security as a full jail