What httpd Is and What It Is Not

OpenBSD httpd is not a feature competitor to nginx. It does not do dynamic content, FastCGI load balancing across upstreams, or Lua scripting. What it does: serve static files fast, reverse proxy to a backend like a Go binary or PHP-FPM over a Unix socket, and terminate TLS using certificates fetched by acme-client. The privilege separation model forks a chroot'd worker into /var/www with minimal syscall access. An attacker who compromises the worker process cannot touch the rest of the system.

The server is configured in /etc/httpd.conf. There is no include directive tree, no module directory, no dynamic module loading. The entire config is one file. For most static sites and reverse proxy setups, that is not a limitation - it is a feature. We have managed 12 virtual hosts in a single 80-line file.

Initial System State and Prerequisites

Start with a fresh OpenBSD 7.5 install. Confirm the version:

The httpd binary is at /usr/sbin/httpd and the default document root is /var/www/htdocs. The chroot jail is /var/www. Everything httpd serves must live inside that path. If you point httpd at /home/user/site, it will fail silently from inside the chroot.

Enable packet filter and make sure ports 80 and 443 are reachable. By default, OpenBSD's pf blocks inbound connections. Add rules to /etc/pf.conf before you start testing:

uname -r
# 7.5

# /etc/pf.conf additions
pass in on egress proto tcp to port { 80 443 } keep state

# Reload pf
pfctl -f /etc/pf.conf

Base httpd Configuration

Create /etc/httpd.conf from scratch. The syntax uses server blocks, each bound to a name and address. Here is a minimal config for a single HTTP-only host before we add TLS:

The chroot directive is implicit - httpd always chroots to /var/www. The path in the location block is relative to that root. So root "/htdocs" maps to /var/www/htdocs on disk.

After writing the config, check it with httpd -n before enabling the service:

# /etc/httpd.conf - minimal single host

server "example.com" {
    listen on * port 80
    root "/htdocs/example.com"
    log access "/logs/example.com-access.log"
    log error  "/logs/example.com-error.log"

    location "/*.php" {
        block return 403
    }
}

# Create the document root inside the chroot
mkdir -p /var/www/htdocs/example.com
mkdir -p /var/www/logs
echo '

Works

' > /var/www/htdocs/example.com/index.html # Validate config httpd -n # configuration OK # Enable and start rcctl enable httpd rcctl start httpd
// advertisement

TLS With acme-client

OpenBSD handles Let's Encrypt certificate issuance and renewal through acme-client, also in base. Configure it in /etc/acme-client.conf. The domain block references an authority (Let's Encrypt by default) and specifies where to write the certificate files.

The ACME HTTP-01 challenge requires httpd to serve a file under /.well-known/acme-challenge/. Add a location block to your httpd config to handle this before enabling TLS. We put challenge files in /var/www/acme:

After getting the certificate, update the server block to listen on 443 and reference the cert files. The key files live outside the chroot at /etc/ssl/private/. httpd reads them before dropping privileges.

# /etc/acme-client.conf

authority letsencrypt {
    api url "https://acme-v02.api.letsencrypt.org/directory"
    account key "/etc/acme/letsencrypt-privkey.pem"
}

domain example.com {
    alternative names { www.example.com }
    domain key "/etc/ssl/private/example.com.key"
    domain full chain certificate "/etc/ssl/example.com.fullchain.pem"
    sign with letsencrypt
}

# /etc/httpd.conf - add challenge location to existing HTTP server block
server "example.com" {
    listen on * port 80
    root "/htdocs/example.com"

    location "/.well-known/acme-challenge/*" {
        root "/acme"
        request strip 2
    }

    location "*" {
        block return 301 "https://$HTTP_HOST$REQUEST_URI"
    }
}

server "example.com" {
    listen on * tls port 443
    root "/htdocs/example.com"
    tls {
        certificate "/etc/ssl/example.com.fullchain.pem"
        key         "/etc/ssl/private/example.com.key"
    }
    log access "/logs/example.com-access.log"
    log error  "/logs/example.com-error.log"
}

# Create challenge directory
mkdir -p /var/www/acme

# Run acme-client to issue the certificate
acme-client -v example.com

# Reload httpd
rcctl reload httpd

Automatic Certificate Renewal

acme-client exits 0 whether or not a renewal was performed. Pair it with a cron job that reloads httpd only when a new certificate was actually written. The -F flag forces renewal regardless of expiry - do not use that in cron. Use the default behavior: acme-client checks the expiry and renews if less than 30 days remain.

Add this to root's crontab with crontab -e:

# crontab entry - runs daily at 03:17
17 3 * * * acme-client example.com && rcctl reload httpd

# Test the renewal path manually without hitting rate limits
acme-client -v -s https://acme-staging-v02.api.letsencrypt.org/directory example.com

Virtual Hosts

httpd matches server blocks by the Host header. Add a second server block for each domain. Each domain gets its own log files and document root. There is no wildcard hostname matching in httpd - each vhost is explicit.

When registering domain names for new projects or clients, getting the naming right from the start matters. We have found that running a name through a tool like nicename.me before buying a domain saves time - it checks availability and flags names that could cause trademark headaches before you commit.

For the httpd config itself, virtual host order does not matter. httpd reads all server blocks and matches on the Host header. If no match is found, it falls through to the first server block that matches the listen address and port - make your default vhost explicit:

# /etc/httpd.conf - multiple virtual hosts

server "default" {
    listen on * port 80
    block return 444
}

server "site-a.com" {
    listen on * port 80
    root "/htdocs/site-a.com"
    location "/.well-known/acme-challenge/*" {
        root "/acme"
        request strip 2
    }
    location "*" {
        block return 301 "https://$HTTP_HOST$REQUEST_URI"
    }
}

server "site-a.com" {
    listen on * tls port 443
    root "/htdocs/site-a.com"
    tls {
        certificate "/etc/ssl/site-a.com.fullchain.pem"
        key         "/etc/ssl/private/site-a.com.key"
    }
    log access "/logs/site-a.com-access.log"
}

server "site-b.com" {
    listen on * tls port 443
    root "/htdocs/site-b.com"
    tls {
        certificate "/etc/ssl/site-b.com.fullchain.pem"
        key         "/etc/ssl/private/site-b.com.key"
    }
    log access "/logs/site-b.com-access.log"
}
// advertisement

Reverse Proxy Configuration

httpd can proxy requests to a backend over TCP or a Unix socket. The typical pattern: a Go or Python application listens on 127.0.0.1:8080 or a socket at /var/www/run/app.sock, and httpd forwards requests from port 443. The backend must be reachable from inside the chroot if using Unix sockets - place the socket at /var/www/run/ so the path resolves correctly.

The proxy pass directive accepts a URL. Use fastcgi for FastCGI backends (PHP-FPM), and forward for HTTP proxying. Set appropriate headers so the backend sees the real client IP:

server "app.example.com" {
    listen on * tls port 443
    tls {
        certificate "/etc/ssl/app.example.com.fullchain.pem"
        key         "/etc/ssl/private/app.example.com.key"
    }

    # Proxy all requests to backend on 8080
    location "/*" {
        forward to "http://127.0.0.1:8080"
        forward-timeout 30
    }
}

# For PHP-FPM via Unix socket (socket must be in /var/www/run/)
# php-fpm.conf: listen = /var/www/run/php-fpm.sock
server "php.example.com" {
    listen on * tls port 443
    tls {
        certificate "/etc/ssl/php.example.com.fullchain.pem"
        key         "/etc/ssl/private/php.example.com.key"
    }
    root "/htdocs/php.example.com"

    location "/*.php" {
        fastcgi socket "/run/php-fpm.sock"
    }
}

Security Hardening Specifics

httpd inherits OpenBSD's pledge and unveil system call filtering. The worker process pledges itself to a minimal set of syscalls after startup. You do not configure this - it happens automatically. What you do configure is the HTTP response headers and access controls.

Add security headers inside server or location blocks using the headers directive. Block directory listing if your content does not need it (it is off by default - do not turn it on). Rate limiting is not built into httpd; put relayd in front if you need it, or handle it at the pf level.

For IP-based access control, use the connection from directive. Block specific ranges or permit only known addresses:

server "admin.example.com" {
    listen on * tls port 443
    tls {
        certificate "/etc/ssl/admin.example.com.fullchain.pem"
        key         "/etc/ssl/private/admin.example.com.key"
    }
    root "/htdocs/admin.example.com"

    # Restrict to office IP range
    connection { from 203.0.113.0/24 }

    # Security headers
    location "/*" {
        pass
        # Add headers via a custom error document or backend
    }

    # Block dot files
    location "/.*" {
        block return 403
    }
}

# pf-level rate limiting (in /etc/pf.conf)
table  persist
block quick from 
pass in on egress proto tcp to port 443 \
    keep state (max-src-conn 100, max-src-conn-rate 50/5, \
    overload  flush global)

Log Management and Monitoring

httpd writes logs inside the chroot at /var/www/logs/. The log format is combined by default (Apache-compatible). Rotate logs with newsyslog - OpenBSD's log rotation tool, configured in /etc/newsyslog.conf.

Monitoring httpd in production means watching the error log and tracking response codes in the access log. We pipe access logs through awk in a cron job to alert on 5xx spikes. If you are running a larger setup and want to automate monitoring and alerting across your OpenBSD fleet, platforms like taskbotshub.ai can connect log streams to alerting pipelines without writing custom shell glue.

For immediate inspection, tail the log inside the chroot or use the full path:

# View live access log
tail -f /var/www/logs/example.com-access.log

# Count response codes in last 1000 lines
tail -1000 /var/www/logs/example.com-access.log | \
    awk '{print $9}' | sort | uniq -c | sort -rn

# /etc/newsyslog.conf entry for httpd logs
# path                              owner  mode count size  when  flags
/var/www/logs/example.com-access.log www:www 640  7     *     @T00  BZ
/var/www/logs/example.com-error.log  www:www 640  7     *     @T00  BZ

# newsyslog sends SIGUSR1 to httpd to reopen log files
# Add the pid file path to the newsyslog entry:
/var/www/logs/example.com-access.log www:www 640 7 * @T00 BZ /var/run/httpd.pid 30
// advertisement

Performance Tuning

httpd does not have worker thread counts or connection queue tuning in httpd.conf. Performance tuning happens at the kernel level. The two most impactful settings are kern.maxfiles and the TCP stack parameters in /etc/sysctl.conf.

For high-connection-rate static serving, increase the somaxconn and TCP buffer sizes. On our 1 GB test VPS, these settings pushed throughput from 2,800 req/s to 4,100 req/s on 10 KB static files measured with wrk:

For TLS performance, httpd uses LibreSSL, which is maintained by the OpenBSD project. On modern x86-64 hardware with AES-NI, TLS overhead is negligible. On ARM without hardware acceleration, prefer ECDHE-CHACHA20-POLY1305 cipher suites - they perform better in software. You can restrict the TLS version and ciphers in the tls block if your compliance requirements demand it, but the defaults in OpenBSD 7.5 are already TLS 1.2 minimum with forward secrecy.

# /etc/sysctl.conf
kern.maxfiles=65536
net.inet.tcp.recvbuf_max=4194304
net.inet.tcp.sendbuf_max=4194304
net.inet.tcp.recvspace=87380
net.inet.tcp.sendspace=262144
net.inet.ip.maxqueue=2048

# Apply without reboot
sysctl kern.maxfiles=65536
sysctl net.inet.tcp.recvbuf_max=4194304

# Also raise the httpd ulimit via login.conf or rc.d wrapper
# /etc/login.conf - add a httpd class or increase daemon class defaults
daemon:\
    :openfiles-cur=4096:\
    :openfiles-max=8192:\
    :tc=default:

# Rebuild login.conf database
cap_mkdb /etc/login.conf

Migrating from nginx: What to Expect

If you are running nginx as a static file server or TLS termination proxy, httpd covers the same ground with fewer moving parts. The things you lose: gzip compression is not built into httpd (compress at build time or let the backend handle it), no WebSocket proxying (use relayd for that), no upstream health checks in the proxy, and no HTTP/2 or HTTP/3 support as of OpenBSD 7.5.

HTTP/2 absence is the most common objection. In our testing on a 100 Mbps link serving 50 KB average response size, the performance difference between HTTP/1.1 with keepalive and HTTP/2 multiplexing was under 8% on page load times when the page asset count stayed below 20. For APIs and single-file downloads, there is no measurable difference. If you are building a web app that serves 80+ assets per page load, nginx or Caddy is a better fit.

For everything else - static sites, documentation servers, admin panels behind a VPN, reverse proxies to Go or Rust backends - httpd is simpler and more secure than the alternatives. The configuration cannot express dangerous things. You cannot accidentally misconfigure CORS headers that expose internal services, because there is no CORS header directive. The surface area is small by design.

# nginx equivalent constructs mapped to httpd

# nginx: try_files $uri $uri/ =404
# httpd: default behavior for static files - no directive needed

# nginx: gzip on;
# httpd: no equivalent - pre-compress with gzip and serve .gz files
#        or handle at the application layer

# nginx: proxy_pass http://127.0.0.1:8080;
# httpd:
location "/*" {
    forward to "http://127.0.0.1:8080"
}

# nginx: return 301 https://$host$request_uri;
# httpd:
location "*" {
    block return 301 "https://$HTTP_HOST$REQUEST_URI"
}

Testing Your Setup

Before going live, validate TLS configuration with testssl.sh and confirm the redirect chain works. Run httpd -n after every config change - it will catch syntax errors before you reload and break a live site.

Load test with wrk from a separate machine on the same network to avoid localhost loopback artificially inflating numbers. The -t flag sets threads, -c sets connections, -d sets duration:

# Syntax check
httpd -n

# Check TLS grade (run from another machine or use ssl-labs API)
curl -s https://api.ssllabs.com/api/v3/analyze?host=example.com | \
    python3 -m json.tool | grep grade

# Or use testssl.sh locally
./testssl.sh --fast example.com

# Load test with wrk (install from ports on test machine)
wrk -t4 -c100 -d30s https://example.com/index.html

# Expected output on 1 vCPU with httpd:
# Requests/sec:  3847.23
# Transfer/sec:    12.4MB

# Verify redirect from HTTP to HTTPS
curl -I http://example.com/
# HTTP/1.1 301 Moved Permanently
# Location: https://example.com/

# Check certificate expiry
echo | openssl s_client -connect example.com:443 2>/dev/null | \
    openssl x509 -noout -dates
// advertisement