I run most of my production sites on FreeBSD behind nginx, and I've written before about deploying a Next.js app behind nginx and the certbot setup that keeps its certificates fresh. That stack works. But there's a lighter path when all you want is "put this Node app on the internet with HTTPS," and that path is Caddy.
Caddy is a single Go binary that does one thing nginx makes you assemble yourself: it gets and renews TLS certificates automatically, with zero configuration beyond an email address. No certbot, no renewal cron job, no ssl_certificate paths to keep in sync. It also speaks HTTP/3 by default — the manual dance HTTP/3 needs under nginx is just on.
This guide is the whole thing end to end: install Caddy on FreeBSD, run it safely as an unprivileged user, keep a Next.js app running, and wire the two together. Every command here is what I actually run.
What you need first
- A FreeBSD host (14.x or 15.x) with root or
sudo. New to the platform? Start with getting started with FreeBSD and hardening SSH before you expose anything. - A domain name with an
A(and ideallyAAAA) record pointing at the box. This is non-negotiable for automatic HTTPS — Caddy proves domain control to Let's Encrypt over ports 80/443, so DNS has to resolve to your server first. - A Next.js project you can build on the host (or build elsewhere and copy the output).
Step 1 — Install Caddy
pkg install caddyThe package gives you the caddy binary, an rc.d service, and a starter config at /usr/local/etc/caddy/Caddyfile. Useful paths to know:
| Thing | Path |
|---|---|
| Config (Caddyfile) | /usr/local/etc/caddy/Caddyfile |
| TLS certificates & keys | /var/db/caddy/data/caddy/ |
| Logs | /var/log/caddy/caddy.log |
You don't manage anything in the certificate directory by hand — Caddy owns it. That's the whole point.
Step 2 — Run Caddy as an unprivileged user
By default the FreeBSD service runs Caddy as root:wheel. The package's own post-install message says it plainly: it's "strongly recommended to run the server as an unprivileged user, such as www:www." A web server facing the internet has no business running as root.
Point the service at the www user:
sysrc caddy_user=www caddy_group=wwwThat immediately raises a problem: ports 80 and 443 are privileged, and www can't bind them. On Linux you'd reach for capabilities; on FreeBSD the clean answer is security/portacl-rc, which wraps the mac_portacl framework so a named user can bind specific low ports:
pkg install portacl-rc
sysrc portacl_users+=www
sysrc portacl_user_www_tcp="http https"
sysrc portacl_user_www_udp="https"
service portacl enable
service portacl startThe udp https line matters — that's the 443/UDP socket Caddy uses for HTTP/3 (QUIC). Skip it and you silently lose HTTP/3.
Step 3 — Build and run the Next.js app
Install Node (any current LTS — swap the version to taste):
pkg install node22 npm-node22From your project directory, install dependencies and build:
npm ci
npm run buildnext start listens on 127.0.0.1:3000 by default. We'll bind it to localhost only and let Caddy be the single thing exposed to the world. Run a quick smoke test:
npm run start # serves on 127.0.0.1:3000Once you've confirmed it boots, you need it to stay up across crashes and reboots. I use PM2 for this, the same way I do when bringing n8n to FreeBSD with PM2 — that post covers the FreeBSD-specific pm2 startup wrinkle in detail, so I'll keep it short here:
npm install -g pm2
pm2 start npm --name web -- run start
pm2 saveNow Node is humming on 127.0.0.1:3000, reachable only from the host itself. Caddy takes it from here.
Step 4 — Write the Caddyfile
Replace /usr/local/etc/caddy/Caddyfile with this. Swap in your domain and email:
{
# Email Let's Encrypt uses for expiry notices and account recovery.
email you@example.com
}
example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
# Sensible security headers for an app you control.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/access.log
}
}That's the entire configuration. The example.com { ... } block is what triggers automatic HTTPS: because you named a real domain, Caddy will obtain a certificate on first start, redirect HTTP→HTTPS for you, and renew the cert in the background for the life of the process. reverse_proxy 127.0.0.1:3000 forwards every request to Next.js. encode zstd gzip turns on compression.
Validate the syntax before you start the service:
caddy validate --config /usr/local/etc/caddy/Caddyfile --adapter caddyfileTesting without a public domain? Use
localhost(or any.localhostname) instead of your real domain and Caddy issues a locally-trusted self-signed certificate — handy for trying this on a laptop before DNS is ready.
Step 5 — Open the firewall
Caddy can't get a certificate if the ACME challenge can't reach it. In pf, allow 80 and 443 over TCP and 443 over UDP (for HTTP/3):
ext_if = "vtnet0"
pass in on $ext_if proto tcp to ($ext_if) port { http https }
pass in on $ext_if proto udp to ($ext_if) port httpsReload and you're open:
pfctl -f /etc/pf.confIf pf is new to you, my walkthrough on setting up pf for hosting a website covers a complete ruleset rather than just these two lines.
Step 6 — Enable and start Caddy
sysrc caddy_enable=YES
service caddy startWatch the first start closely — this is when certificate provisioning happens:
tail -f /var/log/caddy/caddy.logWithin a few seconds you should see Caddy obtain a certificate and start serving. Confirm from another machine:
curl -I https://example.comA HTTP/2 200 (and alt-svc advertising h3) means Next.js is live behind Caddy with a real, auto-renewing certificate. You never touched a certificate file.
Reloading after changes
Edit the Caddyfile, then reload with zero downtime — no restart, no dropped connections:
service caddy reloadCaddy or nginx?
I'm not retiring nginx. For a host juggling many vhosts, fine-grained caching, or an existing nginx config I trust, I stick with it (and sometimes with freenginx). nginx gives you more knobs and a larger body of battle-tested recipes.
But for a single app — exactly the Next.js-on-a-VPS case — Caddy removes the two things most likely to page you at 2 a.m.: an expired certificate and a botched renewal. Automatic TLS and HTTP/3 with a four-line config is hard to argue with. For a personal site or a small product, it's my default now.
Commands tested on FreeBSD 15.0-RELEASE with Caddy 2.x from the official package and Next.js running under Node 22.
