Synopsis #
httpd(8)
is OpenBSD’s native web server daemon. It is simple and security-focused, runs in a chroot(2)
to /var/www by default, serves static content, and dispatches dynamic requests to FastCGI backends. TLS is built in and integrates with acme-client(1)
for automated certificate management.
Web Server History on OpenBSD #
OpenBSD removed the old Apache server from base in 5.6, introduced the native httpd in 5.6, and removed nginx from base in 5.7. Apache and nginx remain available as packages. See the 5.6 release notes and 5.7 release notes .
Web Server Comparison #
The Web overview compares httpd, nginx, and Apache and explains the separate role of relayd.
Configuration #
The configuration file is httpd.conf(5)
. Paths in server and location blocks are relative to the /var/www chroot unless noted otherwise.
/etc/httpd.conf
Minimal configuration to serve static content:
types { include "/usr/share/misc/mime.types" }
server "default" {
listen on * port 80
root "/htdocs"
}
Create the document root and a test page:
doas sh <<'ROOT'
mkdir -p /var/www/htdocs
printf 'Welcome to OpenBSD httpd\n' > /var/www/htdocs/index.html
ROOT
Enable and start the service with rcctl(8) :
doas rcctl enable httpd
doas rcctl start httpd
Validate configuration at any time:
doas httpd -n
Logging #
By default, logs are written inside the chroot:
/var/www/logs/access.log
/var/www/logs/error.log
Follow requests in real time:
doas tail -f /var/www/logs/access.log
Per-server logging can be customized:
server "example.org" {
log style combined
log access "example-access.log"
log error "example-error.log"
root "/htdocs/example"
listen on * port 80
}
TLS with acme-client(1) #
Issue and renew TLS certificates from Let’s Encrypt with acme-client(1) ; configure challenges for every requested name before issuance.
OpenBSD 8.0 defaults to the secure TLS cipher set. Leave tls ciphers unspecified unless a documented compatibility requirement calls for an override. Servers sharing a TLS listener must use compatible client-certificate CA and CRL settings.
Prerequisites #
- DNS A/AAAA records resolve to the server.
- TCP ports 80 and 443 are reachable (adjust pf(4) ).
- The ACME “http-01” challenge path
/.well-known/acme-challenge/is exposed.
Create the standard challenge directory inside the chroot:
doas install -d -o root -g www -m 0755 /var/www/acme
Expose the ACME challenge #
Map the challenge path to /var/www/acme:
server "example.org" {
alias "www.example.org"
listen on * port 80
root "/htdocs/example.org"
# ACME http-01 challenge
location "/.well-known/acme-challenge/*" {
root "/acme"
request strip 2
directory no auto index
}
}
Reload:
doas httpd -n && doas rcctl reload httpd
Configure acme-client(1) #
Create /etc/acme-client.conf (see acme-client.conf(5)
):
authority letsencrypt {
api url "https://acme-v02.api.letsencrypt.org/directory"
account key "/etc/acme/letsencrypt-privkey.pem"
}
domain "example.org" {
alternative names { "www.example.org" }
domain key "/etc/ssl/private/example.org.key"
domain full chain certificate "/etc/ssl/example.org.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
Private keys under /etc/ssl/private/ must be readable by root only.
Obtain the initial certificate #
doas acme-client -v example.org && doas httpd -n && doas rcctl reload httpd
On success, the certificate chain is /etc/ssl/example.org.fullchain.pem and the private key is /etc/ssl/private/example.org.key.
Enable HTTPS and redirect HTTP #
Keep port 80 for ACME challenges and 301 redirects; serve the site on port 443 with HSTS.
types { include "/usr/share/misc/mime.types" }
# HTTP: serve ACME challenges; redirect everything else
server "example.org" {
alias "www.example.org"
listen on * port 80
root "/htdocs/example.org"
location "/.well-known/acme-challenge/*" {
root "/acme"
request strip 2
directory no auto index
}
location * {
block return 301 "https://$HTTP_HOST$REQUEST_URI"
}
}
# HTTPS: primary site
server "example.org" {
listen on * tls port 443
hsts
tls {
certificate "/etc/ssl/example.org.fullchain.pem"
key "/etc/ssl/private/example.org.key"
}
root "/htdocs/example.org"
directory index "index.html"
}
Automated renewal #
Schedule regular renewals and reload only on change; ~ randomizes the minute (see crontab(5)
).
~ 3 * * * acme-client example.org && rcctl reload httpd
OCSP Stapling #
OCSP stapling depends on the issuing CA providing an OCSP responder. Let’s Encrypt ended OCSP support in 2025 , so do not add an OCSP refresh job to the Let’s Encrypt workflow above.
For a different CA that supports OCSP, follow ocspcheck(8)
and configure the resulting response using tls ocsp. That path is absolute, outside the chroot. Validate reload behavior and response expiry; do not assume a missing file is silently ignored.
Common Configuration Examples #
The following TLS snippets are fragments: merge their locations into an HTTPS server with the certificate and key directives shown above. Do not replace a complete TLS server with a fragment lacking those directives.
All directives are documented in httpd.conf(5) .
301 redirects (permanent) #
HTTP→HTTPS (shown above) and common host canonicalization:
# Redirect www to apex
server "www.example.org" {
listen on * port 80
listen on * tls port 443
tls { certificate "/etc/ssl/example.org.fullchain.pem"
key "/etc/ssl/private/example.org.key" }
block return 301 "https://example.org$REQUEST_URI"
}
Directory relocation:
location match "/old%-section/(.*)" {
block return 301 "/new-section/%1?$QUERY_STRING"
}
URL prefix mapping and clean paths #
Use request strip to remove leading components before file lookup.
server "example.org" {
listen on * tls port 443
root "/htdocs"
# Serve /app/* from /var/www/htdocs/app
location "/app/*" {
root "/htdocs/app"
request strip 1
directory index "index.html"
}
}
A single-page application that requires arbitrary routes to serve index.html needs routing support in an application backend or another server. block return produces an HTTP response; it does not serve a local file as a fallback.
HTTP Basic authentication #
OpenBSD’s httpd supports HTTP Basic Authentication for restricting access to specific locations.
Use an htpasswd file #
This approach separates credential management from the primary configuration file and simplifies long-term administration.
- Create a password file
Create a directory within the default chroot and generate password entries with htpasswd(1) . Each invocation prompts for the user’s password and updates the named file:
doas mkdir -p /var/www/auth
doas htpasswd /var/www/auth/mysite alice
doas htpasswd /var/www/auth/mysite bob
doas chown root:www /var/www/auth/mysite
doas chmod 640 /var/www/auth/mysite
The path specified in
httpd.confmust be relative to/var/www(the default chroot environment).
- Reference it in
httpd.conf
server "example.org" {
listen on * tls port 443
root "/htdocs"
location "/private/*" {
authenticate "Restricted Area" with "/auth/mysite"
}
}
Place protected files under /var/www/htdocs/private/. Requests to /private/ require valid credentials. The authentication file is relative to the chroot and must be readable by the www user. Use HTTPS when sending credentials.
httpd(8)does not support inline username/password blocks inhttpd.conf.Use
authenticate ... with "/path/to/htpasswd"as shown above. Theauthentication { user ... }style does not apply to OpenBSDhttpd.
Custom response headers #
OpenBSD 8.0 supports response-header configuration directly in httpd.conf. For example, add this directive inside a server or location block to prevent MIME-type sniffing:
header set "X-Content-Type-Options" "nosniff" always
Without always, a configured header is added only to 2xx and 3xx responses. Use always when it must also appear on error responses. Parse the complete configuration with doas httpd -n before reloading the service.
Static gzip delivery (gzip-static) #
Enable static gzip compression to save bandwidth. When gzip encoding is accepted and a requested file also exists with a .gz suffix, httpd serves the compressed file with Content-Encoding: gzip.
The default static-cache-control behavior sends Cache-Control: no-cache for static responses and, with gzip-static, Vary: Accept-Encoding. The no-cache directive permits storage but requires revalidation; it does not mean no-store. Disabling static-cache-control also disables these headers, so preserve equivalent cache policy when overriding it.
Enable at server or location scope:
server "example.org" {
listen on * tls port 443
root "/htdocs/site"
gzip-static
# Optionally, restrict to an assets subtree
location "/assets/*" {
gzip-static
request strip 1
root "/htdocs/site/assets"
}
}
Precompress assets during deployment. Example for common text types:
doas find /var/www/htdocs/site -type f \( \
-name '*.css' -o -name '*.js' -o -name '*.svg' -o -name '*.html' \) \
-exec gzip -k -9 {} \;
Custom error responses #
Place a custom 404 document in /var/www/errors/404.html. The errdocs directory is relative to the chroot. A request for a missing file retains its 404 status while using that document as the response body.
server "example.org" {
listen on * tls port 443
root "/htdocs/site"
# Return 410 for a retired path
location "/deprecated/*" { block return 410 }
# Load custom error documents from /var/www/errors
errdocs "/errors"
}
FastCGI and Dynamic Content #
Dynamic applications are served via FastCGI.
Using slowcgi(8) (base) #
Enable the wrapper and direct matching locations to its socket:
doas rcctl enable slowcgi
doas rcctl start slowcgi
server "cgi.example.org" {
listen on * port 80
root "/htdocs/cgi.example"
location "*.cgi" {
fastcgi
fastcgi socket "/run/slowcgi.sock" # inside chroot
}
}
Using PHP via php-fpm (packages) #
Install PHP 8.3, the default PHP branch in OpenBSD 8.0. The main package includes PHP-FPM and the php83_fpm service. Its default pool listens at /var/www/run/php-fpm.sock on the host, visible to httpd as /run/php-fpm.sock inside the chroot.
doas pkg_add php%8.3
doas php-fpm-8.3 -t
doas rcctl enable php83_fpm
doas rcctl start php83_fpm
server "php.example.org" {
listen on * port 80
root "/htdocs/php.example"
directory index "index.php"
location "*.php" {
fastcgi
fastcgi socket "/run/php-fpm.sock"
}
}
Test script:
doas sh <<'ROOT'
mkdir -p /var/www/htdocs/php.example
printf '<?php echo "PHP works"; ?>' > /var/www/htdocs/php.example/info.php
ROOT
The default connection max request body limit is 1 MiB. Chunked FastCGI uploads are rejected by default; if explicitly enabled with fastcgi pass chunked, they are also subject to this limit in OpenBSD 8.0. Applications that accept larger uploads require an intentional server or location limit and matching PHP post_max_size and upload_max_filesize settings.
Security Notes #
httpd(8) runs as
wwwand is chrooted to/var/wwwby default. Paths inserver/locationcontexts are relative to the chroot. Paths intls { certificate|key|ocsp }are absolute.Avoid write access on served files for untrusted users. Use chmod(1) and chown(8) .
Keep an HTTP virtual server on port 80 for ACME renewal and to 301-redirect to HTTPS.
Open required ports in pf(4) :
pass in on $ext_if proto tcp to (self) port { 80 443 }
Service Management and Troubleshooting #
Manage the daemon with rcctl(8) :
doas rcctl check httpd
doas rcctl restart httpd
Syntax checks and logs:
doas httpd -n
doas tail -f /var/www/logs/error.log
Common issues:
- ACME challenge returns 404: verify the challenge
locationblock and that/var/www/acmeexists and is readable. - TLS fails to start: confirm readable certificate and key paths; see httpd.conf(5)
tlsblock. - FastCGI errors: confirm backend socket path and permissions inside the chroot.