Skip to content
FreeBSDHow-To Published Updated 6 min readViews unavailable

How to Set Up a Web Server on FreeBSD with nginx

A secure FreeBSD-native nginx deployment covering packages, rc.conf, isolated site configuration, PF validation, TLS issuance, logging, and rollback.

FreeBSD installs third-party software under /usr/local and manages packaged services through rc scripts and rc.conf. A sound nginx deployment therefore differs from a Linux recipe in its paths and service commands, but the larger requirements are universal: controlled content permissions, tested configuration, narrow firewall exposure, valid TLS, security updates, logs, and a rollback path.

Inspect the host and install the intended package

Record the platform, listeners, firewall state, DNS plan, and package vulnerabilities first:

freebsd-version -kru
sockstat -4 -6 -l -P tcp
service pf status
pkg search '^nginx-'
pkg audit -F

FreeBSD offers several nginx package variants with different compiled modules. The standard package is a reasonable baseline when its options meet the site’s needs:

pkg install nginx
nginx -V
pkg info nginx

Review the transaction before accepting it. Do not install a larger variant merely because it has more modules; unnecessary code expands dependencies and attack surface. Conversely, a configuration directive for a module not compiled into the chosen package will fail validation.

Enable nginx and understand FreeBSD paths

Enable the packaged rc service idempotently:

sysrc nginx_enable=YES
sysrc nginx_enable

The main configuration is /usr/local/etc/nginx/nginx.conf, static package content is commonly under /usr/local/www, and logs default under /var/log/nginx. Inspect the actual package rather than assuming a path:

pkg info -l nginx | less
nginx -T

nginx -T prints the effective configuration as well as testing it. Treat its output carefully if configuration contains credentials or private upstream names. Keep a version-controlled, access-restricted copy of local configuration outside the package’s sample files.

Create a read-only document root and isolated site file

For a static site, nginx needs read access but should not own content. Create the directory as root and deploy files with conservative modes:

install -d -o root -g wheel -m 0755 /usr/local/www/example
find /usr/local/www/example -type d -exec chmod 0755 {} +
find /usr/local/www/example -type f -exec chmod 0644 {} +

Run the find commands only after reviewing the target; recursive mode changes can destroy intentional permissions. Application upload directories need a separate, narrowly writable design and must never make executable configuration or the whole site tree writable by nginx.

Place each virtual host in its own file. Ensure the http block in nginx.conf contains exactly one matching include such as:

include /usr/local/etc/nginx/conf.d/*.conf;

Then create /usr/local/etc/nginx/conf.d/example.conf:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    root /usr/local/www/example;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Replace the domain and deploy a known test file. server_name selects a virtual host; it does not create DNS. Point public A and AAAA records only to addresses that reach this host. If IPv6 is advertised, the IPv6 listener, firewall, routing, and TLS path must work too.

Test configuration before every start or reload

Never use service failure as the syntax test:

nginx -t
service nginx start
service nginx status
sockstat -4 -6 -l -P tcp | grep nginx
curl -i -H 'Host: example.com' http://127.0.0.1/

The Host-header request verifies virtual-host selection locally. Also test the site’s real address from another machine, because loopback does not exercise routing, NAT, PF, or upstream firewalls. Confirm that an unknown Host header reaches an intentional default server rather than exposing an unrelated site.

After a configuration change, test first and reload only on success:

nginx -t && service nginx reload

A reload asks workers to adopt the new configuration while old workers finish active requests. It is not proof that every upstream, certificate, file, or client path works. Keep the last known-good configuration and define how to restore it if acceptance tests fail.

Open PF narrowly and validate before loading

If PF is enabled, restrict web ports to the actual external interface and address. A fragment might be:

ext_if = "em0"
pass in on $ext_if proto tcp to ($ext_if) port { 80, 443 } keep state

This is not a complete firewall. Macro definitions, rule ordering, IPv6, NAT, anti-spoofing, management access, and existing anchors matter. Validate the entire file before loading it:

pfctl -nf /etc/pf.conf
pfctl -f /etc/pf.conf
pfctl -sr

Reloading PF remotely can lock out the administrator even when syntax is valid. Use console access or a tested rollback. Also check cloud security groups, provider filters, router port forwarding, and whether another service already occupies the port.

Obtain TLS only after HTTP and DNS work

ACME validation requires control of the name and a reachable challenge path. Verify public DNS from an independent resolver and fetch the challenge directory externally before requesting a certificate. Search the current package flavor rather than copying an old Python suffix:

pkg search -o certbot
pkg install security/py-certbot

For the webroot method:

certbot certonly --webroot \
  -w /usr/local/www/example \
  -d example.com -d www.example.com

Do not reference certificate paths in nginx until issuance succeeds. Certbot stores its managed material under /usr/local/etc/letsencrypt on FreeBSD. Add an HTTPS server using the exact paths reported by Certbot:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name example.com www.example.com;

    root /usr/local/www/example;
    ssl_certificate /usr/local/etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /usr/local/etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        try_files $uri $uri/ =404;
    }
}

Validate, reload, and test from outside with certificate hostname and chain verification enabled. Add an HTTP-to-HTTPS redirect only after the HTTPS site works. Enable HSTS only when the entire domain and relevant subdomains are permanently ready for HTTPS; a premature HSTS policy can make recovery harder.

Certificates expire. Follow the installed Certbot package’s documented FreeBSD automation, run a dry renewal, and ensure successful renewals reload nginx:

certbot renew --dry-run

Monitor actual certificate expiry externally; a scheduled command that silently fails is not renewal.

Monitor, patch, and test failure paths

Review access and error logs at their configured paths:

tail -n 100 /var/log/nginx/access.log
tail -n 100 /var/log/nginx/error.log

Configure rotation and retention without losing errors needed for incident response or retaining personal data indefinitely. Alert on service state, listener absence, elevated server errors, latency, disk space, certificate expiry, and a real HTTPS request. A process can be running while every request fails.

Keep nginx and dependencies patched through the chosen FreeBSD package branch:

pkg audit -F
pkg upgrade nginx
nginx -t && service nginx reload

Review the full upgrade plan and package messages; dependencies may change too. After changes, test the default host, each virtual host, static assets, redirects, IPv4, IPv6 when advertised, TLS chain, application upstreams, logs, and the external firewall path. Finally reboot during a maintenance window to prove rc.conf, storage, DNS, PF, and nginx recover together.

Related:

Sources:

Comments