A copy-pasteable, verified workflow for a single Vultr-hosted FreeBSD 15 box serving a static website privately. The box has public IPv6 only (no public IPv4). Both SSH and the website are reached over Tailscale; nothing is exposed to the public internet. Ordered so you never cut off your own access.
tailscale0). The box's only public exposure is Tailscale's
own UDP — zero public TCP surface.machine.<tailnet>.ts.net on the tailnet. Your laptop ──Tailscale (encrypted mesh)──> box: SSH + HTTPS on tailscale0
Public IPv6 internet ──> box: nothing (only Tailscale UDP passes)
rc.conf spellings drift between
releases. Verify risky lines against your live system (noted inline).Do this from the Vultr web console (no SSH path exists yet).
# Snapshot a clean rollback point FIRST.
# ZFS: bectl create post-install
# UFS: take a Vultr snapshot from the control panel
freebsd-update fetch install # base system patches
pkg update && pkg upgrade # package updates (installs pkg on first run)
freebsd-update will tell you.Still in the Vultr console. Log in as a normal user and escalate deliberately, never as root over SSH.
adduser # interactive — when asked about other groups,
# add the user to: wheel
Choose one escalation tool:
# Option A — doas (recommended: minimal, OpenBSD-native)
pkg install doas
echo 'permit persist keepenv :wheel' > /usr/local/etc/doas.conf
# Option B — sudo (heavier, more Linux-transferable)
pkg install sudo
visudo # uncomment the %wheel line
Verify escalation works:
# As the new user:
doas whoami # should print: root
# NOTE on doas 'persist': run a doas command twice within a minute.
# If it re-prompts for a password every time, persist isn't caching auth
# on your build — a known FreeBSD limitation. Otherwise unaffected.
Still in the Vultr console. This builds your real management path.
pkg install tailscale
sysrc tailscaled_enable=YES
service tailscaled start
# Known gotcha: if 'start' errors on first run, reboot, then continue.
Authenticate the box to your tailnet:
tailscale up
# Copy the printed URL into a browser, approve the machine.
tailscale status # confirm the box appears, note its 100.x.y.z IP
tailscale ip -4 # prints the Tailscale IPv4 (100.x.y.z)
Recommended in the Tailscale admin console:
Now confirm SSH works over Tailscale before changing anything:
# From your LOCAL machine (also on the tailnet, Tailscale running):
ssh youruser@100.x.y.z # password login for now — key comes next
Generate the key on your local machine. The private key never leaves your laptop.
# On your LOCAL machine:
ssh-keygen -t ed25519 -C "you@laptop"
# Accept the default path; set a passphrase (protects the key at rest).
# Copy the PUBLIC key to the server OVER TAILSCALE:
ssh-copy-id -i ~/.ssh/id_ed25519.pub youruser@100.x.y.z
# If ssh-copy-id is unavailable, paste id_ed25519.pub into
# ~youruser/.ssh/authorized_keys on the server manually.
Fix permissions on the server (FreeBSD's StrictModes is on by default; wrong
permissions make key auth fail silently — the #1 "my key won't work" cause):
# On the SERVER, as youruser:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
Test now, before locking anything down:
# From a NEW local terminal:
ssh youruser@100.x.y.z
# Prompted only for your KEY PASSPHRASE, never a server password.
# If it asks for a server password, the key isn't being accepted —
# STOP and fix before Phase 5.
Only proceed once Phase 4's test succeeded. Edit /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
Do not pin ListenAddress to the Tailscale IP. It's tempting as
defense-in-depth, but it creates a boot-time race: at startup sshd often comes
up before tailscaled has assigned tailscale0 its address, so the bind fails
(Bind to port 22 on 100.x.x.x failed: Can't assign requested address), sshd
exits, and you get "connection refused" until you manually restart it. Ordering
sshd after tailscaled does not reliably fix this, because tailscale up
negotiates the address asynchronously. Let sshd bind all interfaces (the
default) and rely on pf (Phase 6) to keep port 22 off the public IPv6. The
security outcome is identical — pf blocks 22 publicly regardless — without the
race.
The KbdInteractiveAuthentication no line is critical: FreeBSD uses PAM by
default, and PAM can prompt for a password through keyboard-interactive even
when PasswordAuthentication no is set. Leave it enabled and passwords can
still get through.
# Confirm which directive your file uses (ChallengeResponseAuthentication is a
# deprecated alias for KbdInteractiveAuthentication):
grep -i kbd /etc/ssh/sshd_config
grep -i challenge /etc/ssh/sshd_config
sshd -t # validate syntax (no output = good)
# run as root, e.g. `doas sshd -t`, or it
# reports host keys missing — it can't read
# the 0600 root-owned keys as a normal user
service sshd restart # `doas service sshd restart` as a normal user
Test from a brand-new terminal while your current session stays open:
ssh youruser@100.x.y.z # should SUCCEED by key
ssh root@100.x.y.z # should be REJECTED
ssh -o PubkeyAuthentication=no youruser@100.x.y.z # should be REJECTED
sshd -t reports "No host key files found," you're running it as a normal
user — it can't read the root-owned host keys. Re-run with doas. If the keys
are genuinely absent (some cloud images defer generation to first boot and it
didn't fire), create them with doas ssh-keygen -A, then re-validate.pf uses OpenBSD syntax you may recognize. FreeBSD ships no default
/etc/pf.conf, so create it. The rules enforce: nothing public except the
Tailscale tunnel; SSH and the web server both ride Tailscale only.
ext_if = "vtnet0" # VERIFY with `ifconfig` — wrong NIC = lockout.
# Vultr is usually vtnet0 (carries public IPv6).
ts_if = "tailscale0" # Tailscale interface
set block-policy drop # silently drop rather than reject
set skip on lo
set skip on $ts_if # trust the Tailscale mesh fully (SSH + web ride here)
scrub in all
# default deny inbound
block in all
pass out all keep state
# IPv6 needs ICMPv6 for neighbor discovery + Path MTU — do NOT block it
pass inet6 proto icmp6 all
# Tailscale tunnel: allow its UDP so direct connections form (else it falls
# back to slower DERP relays). Not strictly required, improves performance.
pass in on $ext_if proto udp to port 41641 keep state
# NOTE: ports 22, 80, and 443 are intentionally absent from $ext_if. SSH and
# the Caddy web server are reachable only via $ts_if, covered by 'set skip'
# above. The box has ZERO public TCP surface — only Tailscale's UDP.
#
# If you ever want to serve a site on the PUBLIC internet instead, add:
# pass in on $ext_if proto tcp to port { 80, 443 } keep state
Enable at boot (pair pf with pflog so you can observe it):
sysrc pf_enable=YES
sysrc pflog_enable=YES # logs to /var/log/pflog
Start pf SAFELY — a wrong rule can drop your session. You now have two safety nets (Tailscale path + Vultr console), but still use the timer:
( sleep 120 && pfctl -d ) & # auto-disables pf in 2 minutes
service pf start
service pflog start
pfctl -sr # show loaded rules; confirm SSH still works
kill %1 # still connected & rules right? cancel the net
tailscale netcheck and
tailscale status (look for a direct connection, not "relay").
Conservative, reversible rc.conf settings via sysrc:
sysrc clear_tmp_enable=YES # wipe /tmp on boot
sysrc syslogd_flags="-ss" # no remote syslog listener
sysrc sendmail_enable=NONE # disable sendmail if you don't need mail
Defensible sysctl settings in /etc/sysctl.conf (all verified accurate):
security.bsd.see_other_uids=0 # users can't see others' processes
security.bsd.see_other_gids=0
security.bsd.unprivileged_read_msgbuf=0
net.inet.tcp.blackhole=2 # drop, don't RST, to closed TCP ports
net.inet.udp.blackhole=1
service sysctl restart # apply without reboot
blackhole complements the firewall; it is not a substitute.freebsd-update fetch install (base) and
pkg upgrade
(packages, including tailscale). Snapshot or bectl create before each.pkg audit -F; consider a cron job./var/log/auth.log for SSH activity. With password auth
off and SSH off the public internet, failed-login noise nearly vanishes.Serve a static page on the tailnet with a real, auto-renewing HTTPS cert from
Tailscale's MagicDNS. No public exposure, no ACME email, no plugin — stock
pkg Caddy fetches *.ts.net certs from the local tailscaled.
<machine>.<tailnet>.ts.net. Used verbatim below.
doas pkg install caddy
The package prints setup notes. Confirmed defaults on caddy 2.11.x:
Caddyfile at /usr/local/etc/caddy/Caddyfile, log at /var/log/caddy/caddy.log
(NOT an access log), cert storage /var/db/caddy/data/caddy/. The package
defaults to running as root:wheel and recommends switching to www:www.
If Caddy ever starts as root first, it creates its dirs root-owned and you'll fix ownership by hand. Set the user/group up front so first start is clean.
# Privileged-port binding for the unprivileged www user
doas pkg install security/portacl-rc
doas sysrc portacl_users+=www
doas sysrc portacl_user_www_tcp="http https"
doas sysrc portacl_user_www_udp="https" # HTTP/3 (QUIC) on UDP 443
# Note: .ts.net certs are fetched from tailscaled, NOT via an ACME port-80
# challenge — so 80 isn't needed for the cert. Caddy still binds 80 for its
# HTTP->HTTPS redirect (useful on the tailnet; blocked publicly by pf).
doas service portacl enable
doas service portacl start
# Run Caddy as www:www — set BOTH (the rc script defaults group to wheel)
doas sysrc caddy_user=www caddy_group=www
sysrc caddy_user caddy_group # confirm both read www
This is the non-obvious part. tailscaled only hands certs to a permitted UID,
set via the TS_PERMIT_CERT_UID environment variable (there is no
--permit-cert-uid flag). On FreeBSD, inject it via the rc.subr-native
tailscaled_env rcvar — no need to edit the package's rc script.
id -u www # get the numeric UID (usually 80)
doas sysrc tailscaled_env="TS_PERMIT_CERT_UID=80" # use the NUMBER, not "www"
doas service tailscaled restart
# VERIFY it actually reached the running daemon (this silently fails otherwise):
doas procstat penv $(doas pgrep -x tailscaled) | grep TS_PERMIT_CERT_UID
# -> envp[N]: TS_PERMIT_CERT_UID=80
Use the numeric UID: older Tailscale accepted only a UID, not a username. pgrep
and procstat need doas to see the root-owned daemon.
doas mkdir -p /usr/local/www/testsite
doas tee /usr/local/www/testsite/index.html >/dev/null <<'EOF'
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>It works</title></head>
<body><h1>Served privately over Tailscale HTTPS.</h1></body></html>
EOF
Replace the Caddyfile entirely (the package ships a default with scaffolding;
leaving it in place produces a nameless server with no cert). Use your real
machine + tailnet name — a literal tailnet-name placeholder yields no cert:
doas tee /usr/local/etc/caddy/Caddyfile >/dev/null <<'EOF'
machine.YOUR-TAILNET.ts.net {
root * /usr/local/www/testsite
file_server
encode gzip
}
EOF
doas caddy fmt --overwrite /usr/local/etc/caddy/Caddyfile
doas caddy validate --config /usr/local/etc/caddy/Caddyfile
# Sanity-check the adapted config actually bound your hostname:
doas caddy adapt --config /usr/local/etc/caddy/Caddyfile --pretty | grep -i host
doas service caddy enable
doas service caddy start
ls -ld /var/log/caddy /var/db/caddy /var/run/caddy # all www:www
The cert is fetched from tailscaled on the first TLS handshake, NOT at
startup — so the startup log shows no ACME "obtaining certificate" line.
That absence is normal for Tailscale certs; don't chase it. The real test is
hitting the site from another tailnet device:
curl -v https://machine.YOUR-TAILNET.ts.net/ # 200, valid cert, your HTML
A startup log line you WILL see and can ignore:
server is listening only on the HTTPS port but has no TLS connection policies — harmless with
Tailscale
certs.
Already handled by the Phase 6 ruleset (no $ext_if pass for 80/443). If you
previously opened them, remove that rule and reload:
doas pfctl -f /etc/pf.conf
doas pfctl -sr # no http/https pass rules
See "Verifying a Closed Port on a Cloud Host" below — do NOT use nc -vz from
outside to check this; it lies on Vultr.
Nothing to configure. Caddy re-fetches from tailscaled as the 90-day cert
nears expiry, and Tailscale renews it.
Prove each claim rather than assuming it.
1. New terminal: ssh youruser@100.x.y.z -> succeeds by key, no pw
2. ssh root@100.x.y.z -> rejected
3. ssh -o PreferredAuthentications=password \
-o PubkeyAuthentication=no youruser@100.x.y.z -> rejected
4. doas whoami (as your user) -> prints root
5. Confirm nothing public is listening (ON THE BOX):
sockstat -6 -l -> no public :22/:80/:443
doas pfctl -sr -> no pass for 22/80/443
Do NOT trust `nc -vz` from outside — see "Verifying a Closed Port" below.
6. doas sshd -t -> clean, no errors
7. tailscale status -> direct (not relay) ideal
8. REBOOT, then:
pfctl -s info -> pf is up
tailscale status -> reconnected
ssh youruser@100.x.y.z -> still works by key
If all pass, you have a defensible baseline: SSH and the website both private to your tailnet, zero public TCP surface, and the fundamentals in place to layer Bastille on top.
Symptom: ssh: connect to host ..., port 22: Connection refused, and on the
box service sshd status reports sshd is not running.
Diagnose from the Vultr console (don't change anything yet):
service sshd status # running?
sockstat -4 -6 -l | grep ':22' # anything bound to :22?
grep -i sshd /var/log/auth.log | tail -30 # why did it exit?
If you see Bind to port 22 on 100.x.x.x failed: Can't assign requested address, the cause is the
boot-time race: sshd tried to bind the Tailscale
address before tailscale0 had it. This is exactly why this workflow does
not pin ListenAddress (see Phase 5). Confirm there's no ListenAddress
line pointing at the Tailscale IP:
grep -i listenaddress /etc/ssh/sshd_config
Immediate recovery (works because tailscale0 is up by the time you're typing):
doas service sshd restart
sockstat -4 -6 -l | grep ':22' # now bound
Permanent fix: remove the ListenAddress pin so sshd binds all interfaces; pf
keeps port 22 off the public IPv6. Verify it survives a reboot — that's the
test that actually matters, since boot is where it failed.
You're running it as a normal user; it can't read the root-owned host keys. Use
doas sshd -t. If the keys are truly missing, generate them with
doas ssh-keygen -A, then re-validate and restart.
nc -vz <public-ip> <port> is not reliable on Vultr (and similar clouds)
for confirming a port is closed. Their edge network completes the TCP handshake
for common web ports (80/443) before packets reach your instance, so nc
reports "succeeded" even when your host is fully closed and nothing is
listening. This wastes hours chasing a firewall bug that doesn't exist.
Trust host-side evidence over external port probes. Four independent checks, strongest first:
# 1. Nothing is listening publicly (ground truth):
sockstat -6 -l | grep -E ':(80|443|22)' # empty = nothing bound publicly
# 2. pf has no pass rule and defaults to block:
doas pfctl -sr # no pass for those ports
doas pfctl -si | grep -i status # Status: Enabled
# 3. No inbound SYN to those ports ever reaches the host:
doas tcpdump -ni vtnet0 'tcp[tcpflags] & tcp-syn != 0 and (port 80 or port 443)'
# Run an external connection attempt; if NOTHING appears here, the packet
# never reached you.
The one external test that is meaningful is an application-layer request, because the edge can fake the handshake but cannot serve your content:
# From outside the tailnet:
curl -6 -v --max-time 8 https://[<public-v6>]/
# Expected when closed: "Connected" (edge handshake) then TIMEOUT with no
# response. TCP connects but no HTTP/TLS data comes back = no reachable server.
If curl connects but times out with zero bytes / no ServerHello, the host is
closed regardless of what nc -vz claimed. Optionally also block 80/443 in a
Vultr Firewall Group for belt-and-suspenders, which also makes external probes
behave as expected.
A copy-pasteable continuation of the "FreeBSD 15 Post-Install" workflow. Picks up after Phase 9 (static site served by Caddy over a Tailscale MagicDNS cert) and adds PocketBase as a development/testing backend on the same box, reached only over Tailscale. PocketBase listens on localhost; your existing Caddy terminates TLS and reverse-proxies to it. The box's zero-public-TCP-surface model is preserved unchanged.
127.0.0.1:8090 only — never a public or Tailscale
address. The single thing that talks to it is Caddy, on the same host.*.ts.net MagicDNS cert from Phase 9.
Reachable only at machine.<tailnet>.ts.net on the tailnet. tailnet device ──Tailscale──> Caddy (TLS, :443 on tailscale0)
└─reverse_proxy──> 127.0.0.1:8090 (PocketBase)
Public IPv6 internet ──> nothing (only Tailscale UDP passes)
doas, Tailscale up with key expiry disabled, Caddy installed and
serving the Phase 9 static site as www:www via portacl, pf enabled with no
public TCP pass rules, and HTTPS certificates enabled in the Tailscale admin
console.doas. No console needed; nothing here can lock
you out (you're not touching sshd or pf rules).freebsd/amd64 and
freebsd/arm64 are officially supported Go build targets, so you build from
source. This is sanctioned, not a workaround.
Install the Go toolchain and build the canonical standalone binary. The
examples/base build is byte-for-byte the source the official prebuilt
executables come from — same admin UI, same JS extensibility plugin.
doas pkg install go git
# As your NORMAL user (not root, not the service user):
go install github.com/pocketbase/pocketbase/examples/base@latest
# Binary lands at ~/go/bin/base
Pin a known version instead of @latest if you want reproducibility:
# Example — check https://github.com/pocketbase/pocketbase/releases for current:
go install github.com/pocketbase/pocketbase/examples/base@v0.39.0
Stage the binary into place and confirm it runs:
doas cp ~/go/bin/base /usr/local/bin/pocketbase
/usr/local/bin/pocketbase --version # prints the PocketBase version
go install fails on a network-restricted box, it needs outbound HTTPS to
proxy.golang.org and GitHub. Build on a machine that has it, then copy the
resulting binary over — it's a single static executable.
On a memory-light instance (e.g. a 512 MB Vultr box) the build is killed
mid-compile — the Go compiler runs out of RAM on PocketBase's heaviest
dependencies (modernc.org/sqlite/libc). The symptom is a bare Killed from
go install, and dmesg | tail showing
pid NNN (compile) ... killed: out of swap space / failed to reclaim memory. The
downloads succeed; compilation is
what dies.
Confirm it's memory and check you have room for a swap file:
sysctl hw.physmem # physical RAM in bytes (512 MB ~= 500000000)
swapinfo -h # often empty on small cloud images = no swap
df -h / # need a few GB free for the swap file
Add a temporary 2 GB swap file (works on UFS or ZFS; sized generously because
the compile leans on it hard). FreeBSD attaches file-backed swap via md(4):
doas truncate -s 2G /swapfile0
doas chmod 600 /swapfile0
doas mdconfig -a -t vnode -f /swapfile0 -u 0
doas swapon /dev/md0
swapinfo -h # confirm /dev/md0, 2.0G present
Build with parallelism capped to one compile job at a time. A single heavy
package is what blows the budget, so -p=1 keeps multiple compile processes
from faulting into swap simultaneously — slower, far less thrashing:
go env -w GOFLAGS=-p=1
go install github.com/pocketbase/pocketbase/examples/base@latest
go env -u GOFLAGS
Expect several minutes and heavy disk activity; watch swapinfo -h from another
session if you like. Once the binary is built and staged, the swap is no longer
needed for the build. Either remove it, or keep a smaller persistent swap (a
modest swap is sensible on a small box for runtime headroom):
# Remove entirely:
doas swapoff /dev/md0
doas mdconfig -d -u 0
doas rm /swapfile0
# OR keep a persistent 1 GB across reboots (do the swapoff/mdconfig/rm above
# first to drop the 2 GB build-time file), then:
doas truncate -s 1G /swapfile
doas chown root:wheel /swapfile # MUST be root-owned or mdconfig attaches it
doas chmod 600 /swapfile # readonly, and swap silently refuses readonly
# File-backed swap on FreeBSD needs an md(4) wrapper — a plain "/swapfile ...
# sw" line fails with "Block device required". Use the md + file= form (a bare
# "md" auto-selects the unit number, which is fine).
# The ",late" keyword is REQUIRED for a swapfile on the root filesystem: swap is
# activated in two passes, and the early /etc/rc.d/swap pass runs before root is
# mounted read-write. Without ",late" the early pass fails to attach the file
# and never retries, so swap is absent after boot even though the line is read.
# The late /etc/rc.d/swaplate pass runs after root is read-write, where it works.
echo 'md none swap sw,file=/swapfile,late 0 0' \
| doas tee -a /etc/fstab
doas swapon -aL # activate now (use -aL, NOT -aqL: the q hides
# errors you need to see; -L processes "late")
doas mdconfig -lv # the /swapfile md line must NOT say "readonly"
swapinfo -h # device listed with 1.0G avail = working
If swapon errors with mdconfig (attach) error or the device shows up
readonly in mdconfig -lv, a stale md unit from an earlier attempt is usually
attached and colliding. Detach it and retry clean:
doas swapoff -aL 2>/dev/null
doas mdconfig -lv # note any md unit still bound to /swapfile
doas mdconfig -d -u 0 # detach it (adjust unit number to match)
doas mdconfig -lv # now empty
doas swapon -aL # re-attach clean — should be read-write now
GOOS=freebsd GOARCH=amd64 (match uname -m on the box) and
scp
the single binary over — no toolchain or swap needed on the box at all.md none swap sw,file=/swapfile,late 0 0 fstab form wraps the file in an
md(4) device at boot (a plain /swapfile ... sw line fails — FreeBSD
swapon requires a block device). The file must be root-owned mode 600 or the
md attaches readonly and swap silently refuses it. The ,late keyword is the
part most often missed: a swapfile on the root filesystem must be attached by
the late boot pass (after root is read-write), and omitting ,late leaves swap
absent after reboot even though manual swapon -aL works. Always verify with a
reboot: swapinfo -h should show the /dev/md<N> device, read-write, after a
clean boot.
total configured swap ... exceeds maximum recommended amount on a low-RAM
box — this is advisory (default swap-zone sizing relative
to RAM), not a failure, and does not prevent swap from working. Ignore it, or
use a smaller swapfile to silence it.Run PocketBase as a dedicated unprivileged user with no shell, owning its own data directory. Never as root, never as your login user.
doas pw useradd pocketbase -d /var/db/pocketbase -s /usr/sbin/nologin \
-c "PocketBase service"
doas mkdir -p /var/db/pocketbase/pb_data
doas chown -R pocketbase:pocketbase /var/db/pocketbase
PocketBase keeps everything — the SQLite database, uploaded files, migrations —
inside the directory passed to --dir. Pointing it at
/var/db/pocketbase/pb_data keeps state off your home directory and under a
predictable, backup-friendly path.
FreeBSD ships no PocketBase rc script. Create one. PocketBase runs in the
foreground, so this uses daemon(8) to background and supervise it: -r
restarts it if it crashes (handy on a dev box you'll be poking at), -u drops
to the service user, -P writes the supervisor's pidfile, -o captures output.
doas tee /usr/local/etc/rc.d/pocketbase >/dev/null <<'EOF'
#!/bin/sh
#
# PROVIDE: pocketbase
# REQUIRE: NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="pocketbase"
rcvar="pocketbase_enable"
pidfile="/var/run/${name}.pid"
logfile="/var/log/${name}.log"
pocketbase_user="pocketbase"
pocketbase_data="/var/db/pocketbase/pb_data"
command="/usr/local/bin/pocketbase"
command_args="serve --http 127.0.0.1:8090 --dir ${pocketbase_data}"
start_cmd="pocketbase_start"
stop_cmd="pocketbase_stop"
status_cmd="pocketbase_status"
pocketbase_start() {
/usr/sbin/daemon -P ${pidfile} -r -f -u ${pocketbase_user} \
-o ${logfile} ${command} ${command_args}
}
pocketbase_stop() {
if [ -e "${pidfile}" ]; then
kill -TERM "$(cat ${pidfile})"
else
echo "${name} is not running"
fi
}
pocketbase_status() {
if [ -e "${pidfile}" ] && kill -0 "$(cat ${pidfile})" 2>/dev/null; then
echo "${name} is running as pid $(cat ${pidfile})."
else
echo "${name} is not running."
return 1
fi
}
load_rc_config $name
: ${pocketbase_enable:=NO}
run_rc_command "$1"
EOF
doas chmod +x /usr/local/etc/rc.d/pocketbase
doas touch /var/log/pocketbase.log
doas chown pocketbase:pocketbase /var/log/pocketbase.log
Note on the pidfile: the PID recorded is the daemon supervisor's, not
PocketBase's. That is correct here — with -r, stopping the supervisor is what
cleanly takes the child down too; killing the child directly would just trigger
a restart.
This is also why the script defines a custom status_cmd. rc.subr's default
status matches the pidfile PID against a process named like the command
(pocketbase), but the pidfile holds the supervisor's PID, whose process name
is daemon. The default check therefore reports "not running" even when it is.
The custom pocketbase_status instead just tests whether the pidfile PID is
alive (kill -0), which is unambiguous. Because the pidfile is root-owned, run
status with doas (doas service pocketbase status) or it can't read it.
Enable and start:
doas sysrc pocketbase_enable=YES
doas service pocketbase start
doas service pocketbase status # "running as pid N" (needs doas)
Confirm it bound to loopback only — this is the critical check:
sockstat -4 -l | grep 8090
# MUST show 127.0.0.1:8090. If it shows *:8090, STOP — it's listening on all
# interfaces and only pf stands between it and the public IPv6. Fix --http first.
tail -20 /var/log/pocketbase.log.
Add a site block to your existing Caddyfile. This uses PocketBase's official
reverse-proxy shape, not a bare reverse_proxy: the long read_timeout keeps
realtime SSE subscriptions alive (a short proxy timeout silently severs them),
and request_body max_size caps uploads.
Decide first whether PocketBase replaces the Phase 9 static site on this hostname or shares it. A subdomain/own-root is strongly preferred over a subpath — PocketBase's own docs note subpaths break same-origin isolation for localStorage and other browser resources, and its admin UI assumes it lives at root. With one MagicDNS name, "own root" is the practical choice.
Replace the Phase 9 site block (or add a new one if you kept the static site on a different name) with:
machine.YOUR-TAILNET.ts.net {
request_body {
max_size 10MB
}
reverse_proxy 127.0.0.1:8090 {
transport http {
read_timeout 360s
}
}
encode gzip
}
max_size 10MB is PocketBase's example default. Raise it if you upload larger
files through PocketBase file fields (PocketBase enforces its own per-field
limits independently).Validate and reload (reload, not restart — no cert re-fetch needed):
doas caddy validate --config /usr/local/etc/caddy/Caddyfile
doas caddy fmt --overwrite /usr/local/etc/caddy/Caddyfile
doas service caddy reload
Create the first superuser. Two ways — pick one.
# Option A — explicit CLI (deterministic, scriptable):
doas -u pocketbase /usr/local/bin/pocketbase superuser create \
you@example.com 'a-strong-password' --dir /var/db/pocketbase/pb_data
# Option B — use the one-time setup URL printed in the log on first run:
tail -20 /var/log/pocketbase.log # open the printed install URL on a tailnet device
Verify the whole path end-to-end from another tailnet device:
curl -v https://machine.YOUR-TAILNET.ts.net/_/ # admin UI, 200, valid cert
Then open that URL in a browser on a tailnet device and log in.
Now set the client-IP proxy header — do not skip this. Behind a reverse
proxy, PocketBase otherwise logs every request as coming from 127.0.0.1, and
its builtin rate limiter then buckets all clients together. In the dashboard:
reverse_proxy sets this automatically).All built into recent PocketBase; all optional for a private dev/testing box on your tailnet, but cheap to enable.
_superusers collection settings).
Adds an email one-time code on superuser login.--encryptionEnv. To inject the env var on FreeBSD, add it
via the rc script's command_args environment or a wrapper; verify it reached
the process before relying on it.On email: Phase 7 set sendmail_enable=NONE, so PocketBase's default mail
path is dead. That's fine until you test auth flows (verification, password
reset), which will stall without working mail. When you need them, configure an
external SMTP relay in Dashboard -> Settings -> Mail settings rather than
re-enabling local sendmail (local sendmail mail usually lands in spam anyway).
PocketBase has builtin backups (Dashboard -> Settings -> Backups) that snapshot
pb_data to a ZIP; during generation the app is briefly read-only. For a small
dev database this is enough. To automate an off-box copy, pair it with your
Phase 8 snapshot habit:
# Simple nightly local snapshot via cron, owned by the service user.
# For transactional safety on larger DBs, prefer `sqlite3 .backup` + rsync
# (see the PocketBase backup.sh discussion) over copying a live file.
doas crontab -u pocketbase -e
# Add, e.g. (3:15 AM daily), assuming you create /var/db/pocketbase/backups:
# 15 3 * * * /usr/local/bin/pocketbase superuser ... # or trigger the backup API
bectl create / Vultr-snapshot before PocketBase upgrades.Prove each claim rather than assuming it. Fold these into the original guide's checklist.
1. doas sockstat -4 -l | grep 8090 -> 127.0.0.1:8090 ONLY (never *:8090)
2. doas pfctl -sr -> still NO $ext_if pass for 8090
(only ICMPv6 + udp 41641 on vtnet0)
3. curl -fsS http://127.0.0.1:8090/api/health (on the box)
-> {"message":"API is healthy.","code":200,...}
4. https://machine.YOUR-TAILNET.ts.net/_/ (browser, from a tailnet device)
-> admin UI loads, valid cert
5. Prove public IPv6 closure — TWO checks (see lesson below):
doas sockstat -6 -l | grep 8090 -> EMPTY (nothing on public v6)
# from a NON-tailnet host:
curl -6 -v --max-time 8 'http://[<public-v6>]:8090/'
-> "Connection timed out" (curl: 28),
no handshake, no response = closed
6. doas service pocketbase stop
doas sockstat -4 -l | grep 8090 -> gone (clean stop, no orphan)
doas service pocketbase status -> "not running" (status is honest)
doas service pocketbase start -> back, bound to 127.0.0.1, new pid
7. Set the proxy header (Phase 14), then make a request from a tailnet device
and check PocketBase logs -> client IP is the real 100.x address, not 127.0.0.1
8. REBOOT, then:
doas service pocketbase status -> "running as pid N" (came up at boot)
tailscale status -> reconnected (100.x address shown)
curl -fsS http://127.0.0.1:8090/api/health -> 200 (PocketBase up)
https://machine.YOUR-TAILNET.ts.net/_/ (browser) -> admin UI reachable
If all pass: PocketBase is private to your tailnet, loopback-bound, fronted by your existing Caddy cert, with zero added public surface.
Item 5 is the one most easily gotten wrong. A browser is the wrong tool: Firefox
with HTTPS-Only Mode enabled will intercept http://[v6]:8090/ and show "Secure
Site Not Available" before completing any connection. That dialog is a
client-side artifact — it does NOT tell you whether the TCP port is reachable,
because it's reporting on the absence of HTTPS, not on the port's state.
Verify with host-side evidence plus a raw curl instead:
doas sockstat -6 -l | grep 8090 empty proves nothing is even listening on
the public IPv6 — PocketBase binds 127.0.0.1 only, so there's nothing there
to reach regardless of pf.curl -6 -v --max-time 8 from outside the tailnet timing out with curl: (28)
and no completed handshake proves pf's block drop in all is silently dropping
the inbound SYN. A timeout (not "connection refused") is the expected signal
of a drop policy. Note this trustworthy result is specific to 8090: do NOT
use the same probe on 80/443, where the cloud edge fakes the handshake (see the
original guide's "Verifying a Closed Port on a Cloud Host").status says "not running" but it actually isIf you used an rc script without the custom status_cmd from Phase 12, this
is expected: rc.subr's default status matches the pidfile PID against a process
named pocketbase, but the pidfile holds the daemon supervisor's PID (named
daemon), so the match fails. Prove it's actually up:
doas pgrep -fl pocketbase # shows the daemon supervisor AND child
curl -fsS http://127.0.0.1:8090/api/health # {"code":200,...} = it's serving
Fix: add the pocketbase_status function and status_cmd="pocketbase_status"
from Phase 12, then run status with doas (it must read the root-owned
pidfile).
curl to /_/ returns 502 Bad GatewayCaddy is up but can't reach PocketBase. Check, in order:
doas service pocketbase status # running? (needs doas)
sockstat -4 -l | grep 8090 # bound to 127.0.0.1:8090?
tail -30 /var/log/pocketbase.log # did it exit on startup? why?
A 502 with PocketBase running usually means a host/port mismatch between the
Caddyfile reverse_proxy target and PocketBase's --http flag. They must both
be 127.0.0.1:8090.
The proxy read_timeout is too short or absent. Confirm the Caddyfile has the
transport http { read_timeout 360s } block inside reverse_proxy, then
doas service caddy reload.
The proxy header isn't set in PocketBase. Dashboard -> Settings -> Application ->
enable the client-IP header and trust X-Forwarded-For. Caddy already sends it.
service pocketbase start does nothing / no pidfileRun as root via doas, and confirm the log is writable by the service user:
ls -l /var/log/pocketbase.log # should be pocketbase:pocketbase
doas service pocketbase start
ps aux | grep '[p]ocketbase' # daemon supervisor + child present?
cat /var/run/pocketbase.pid # supervisor PID
If the binary itself fails, run it once in the foreground as the service user to see the error directly:
doas -u pocketbase /usr/local/bin/pocketbase serve \
--http 127.0.0.1:8090 --dir /var/db/pocketbase/pb_data