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 ideally AAAA) 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 caddy

The 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=www

That 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 start

The 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-node22

From your project directory, install dependencies and build:

npm ci
npm run build

next 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:3000

Once 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 save

Now 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 caddyfile

Testing without a public domain? Use localhost (or any .localhost name) 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 https

Reload and you're open:

pfctl -f /etc/pf.conf

If 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 start

Watch the first start closely — this is when certificate provisioning happens:

tail -f /var/log/caddy/caddy.log

Within a few seconds you should see Caddy obtain a certificate and start serving. Confirm from another machine:

curl -I https://example.com

A 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 reload

Caddy 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.