🌐 OpenBSD httpd

A small web server that comes with the system, runs in a chroot as an unprivileged user, and covers static sites, virtual hosts, passwords and TLS. Every example run for real, with the errors you will meet.

Video: OpenBSD httpd (OpenBSD video)
Prefer to watch? This short video runs this page's main commands on a real OpenBSD 8.0 machine, with on-screen captions and a synthetic voiceover (an AI voice trained on the author's own recording). Click to play, or watch it on YouTube.
server "two.example" {
	listen on 127.0.0.1 tls port 443
	tls { certificate "/etc/ssl/two.example.crt"  key "/etc/ssl/private/two.example.key" }
	root "/htdocs/site2"
}

OpenBSD includes a web server, httpd(8), in the base system. It needs no installation, it is updated by syspatch, and it runs locked into /var/www as the user www. This page sets up a first site, reads the logs, serves two sites from one server, adds an automatic index, a redirect and a password, turns on TLS, runs a CGI program with slowcgi, and shows the errors you are likely to meet.

Every example ran for real on OpenBSD 8.0 in a virtual machine, as an ordinary user, with root commands marked doas. There is no curl in the base system, so the tests use ftp(1) and nc, which are. The output you see is the output the machine gave.

A web server that comes with the system

OpenBSD ships its own web server, httpd(8). It is small, it was written to be safe rather than to have every feature, and it is part of the base system, so it needs no installation and is updated by syspatch like everything else. It serves static files, virtual hosts, redirects, password-protected folders and TLS, and it can run FastCGI programs. That covers a lot of real sites. For PHP or a database you would add more pieces, but the web server stays the same.

Two ideas to know first. httpd runs in a chroot: it is locked into the directory /var/www, and from the point of view of its configuration the root of the file system is /var/www. A document root of /htdocs in the configuration is /var/www/htdocs on the disk. And it runs as the unprivileged user www. A bug in the server cannot reach the rest of the machine. Every example here ran for real on OpenBSD 8.0 in a virtual machine, as an ordinary user called bsd with root commands marked doas. Look at what is there before starting:

$ doas rcctl get httpd
httpd_class=daemon
httpd_execdir=
httpd_flags=NO
httpd_logger=
httpd_rtable=0
httpd_timeout=30
httpd_user=root
(exit status 1)
$ ls /var/www
acme
bin
cache
cgi-bin
conf
htdocs
logs
run
$ ls /etc/examples/httpd.conf
/etc/examples/httpd.conf
$ man -k httpd | head -n 3
httpd.conf(5) - HTTP daemon configuration file
httpd(8) - HTTP daemon

httpd is installed and switched off, httpd_flags=NO. The exit status 1 only means the service is not enabled. The directory /var/www holds the chroot, with htdocs for the pages, logs for the logs, and conf for password files. The man pages httpd(8) and httpd.conf(5) are the reference, and /etc/examples/httpd.conf is a starting point.

The first site

A configuration is a list of server blocks. The smallest useful one listens on an address and a port and says where the files are. We listen on 127.0.0.1 only, so that nothing outside the machine can reach the test. Write it to a temporary file first, because httpd can check a file before you use it:

$ cat /tmp/h1.conf
server "default" {
	listen on 127.0.0.1 port 80
	root "/htdocs"
}
$ doas httpd -n -f /tmp/h1.conf
configuration OK

The -n option only checks the syntax and says configuration OK. Now install it as the real configuration, make a page, and start the server the OpenBSD way, with rcctl. enable makes it start at boot, start starts it now:

$ doas cp /tmp/h1.conf /etc/httpd.conf
$ echo '<h1>Hello from httpd</h1>' | doas tee /var/www/htdocs/index.html
<h1>Hello from httpd</h1>
$ doas rcctl enable httpd
$ doas rcctl start httpd
httpd(ok)
$ doas rcctl check httpd
httpd(ok)

Fetch the page. OpenBSD has no curl in the base system, but ftp(1) does HTTP and HTTPS, and nc is there for raw requests:

$ ftp -o - http://127.0.0.1/
<h1>Hello from httpd</h1>

Three kinds of httpd process run. The parent reads the configuration and is the only one that stays root. The servers that answer requests, and the logger that writes the logs, run as the user www:

$ ps -ax -o user,args | grep '[h]ttpd'
root     /usr/sbin/httpd
www      httpd: server (httpd)
www      httpd: server (httpd)
www      httpd: server (httpd)
www      httpd: logger (httpd)

The names in the last column say what each process does. Because the parent is root, it can read a private key and open ports below 1024, and the processes that touch the network never have those powers.

Logs

The logs are in the chroot, in /var/www/logs. access.log has one line per request, with the name of the server block first, then the client, the time, the request and the status code. error.log has the problems. Ask for a page that does not exist and read the access log:

$ ftp -o - http://127.0.0.1/nope
ftp: Error retrieving http://127.0.0.1/nope: 404 Not Found
(exit status 1)
$ doas tail -n 3 /var/www/logs/access.log
default 127.0.0.1 - - [06/Nov/2026:13:03:14 +0000] "GET / HTTP/1.1" 200 26
default 127.0.0.1 - - [06/Nov/2026:13:03:15 +0000] "GET /nope HTTP/1.1" 404 0

The 404 is in the log, and the 200 for the first page. The format is the common log format with the server name added in front, so the usual tools can read it. To count the status codes, cut out the field:

$ doas awk '{print $(NF-1)}' /var/www/logs/access.log | sort | uniq -c
   1 200
   1 404

The files grow without limit, so the base system rotates them. newsyslog does it from cron, and the www logs are already listed in /etc/newsyslog.conf. After a rotation it sends httpd the USR1 signal, so that it opens new files:

$ grep www /etc/newsyslog.conf
/var/www/logs/access.log		644  4     *    $W0   Z "pkill -USR1 -u root -U root -x httpd"
/var/www/logs/error.log			644  7     250  *     Z "pkill -USR1 -u root -U root -x httpd"

Two sites on one server

One httpd can serve many names, which are called virtual hosts. The server block name is matched against the Host header the client sends. We have no DNS here, so give the names to this machine in /etc/hosts. Then make a root directory and a page for each site:

$ echo '127.0.0.1 one.example two.example' | doas tee -a /etc/hosts
127.0.0.1 one.example two.example
$ doas mkdir -p /var/www/htdocs/site1 /var/www/htdocs/site2
$ echo 'site one' | doas tee /var/www/htdocs/site1/index.html
site one
$ echo 'site two' | doas tee /var/www/htdocs/site2/index.html
site two

$ cat /tmp/h2.conf
server "one.example" {
	listen on 127.0.0.1 port 80
	root "/htdocs/site1"
}
server "two.example" {
	listen on 127.0.0.1 port 80
	root "/htdocs/site2"
}
$ doas httpd -n -f /tmp/h2.conf
configuration OK
$ doas cp /tmp/h2.conf /etc/httpd.conf
$ doas rcctl reload httpd
httpd(ok)

reload reads the configuration again without dropping connections, and is what you use for every change after the first start. Ask for each name, and for the address with no name:

$ ftp -o - http://one.example/
site one
$ ftp -o - http://two.example/
site two
$ ftp -o - http://127.0.0.1/
site one

The plain address matched no name, so the first block, one.example, answered it. Put the site you want as the default first. The access log says which block answered each request, in its first column:

$ doas tail -n 3 /var/www/logs/access.log | cut -c1-60
one.example 127.0.0.1 - - [06/Nov/2026:13:03:19 +0000] "GET
two.example 127.0.0.1 - - [06/Nov/2026:13:03:19 +0000] "GET
one.example 127.0.0.1 - - [06/Nov/2026:13:03:19 +0000] "GET

Locations: indexes, redirects and passwords

A location block changes the rules for part of a site. Three are used all the time. directory auto index lists a folder that has no index page. block return answers with a status and no content, and with a code like 301 and a URL it is a redirect. authenticate with asks for a password from a file. Make the folders first:

$ doas mkdir -p /var/www/htdocs/site2/pub /var/www/htdocs/site2/private
$ echo hello | doas tee /var/www/htdocs/site2/pub/a.txt
hello
$ echo secret | doas tee /var/www/htdocs/site2/private/index.html
secret

The password file holds a user name and a bcrypt hash. The encrypt(1) command makes the hash. The file is read by the server before it enters the chroot, but inside the chroot it is the path that counts, so keep it under /var/www/conf and make it readable by the group www:

$ echo "ann:$(encrypt secret1)" | doas tee /var/www/conf/htpasswd | cut -c1-30
ann:$2b$11$rD27K7obMsCIpMGW5J7
$ doas chown root:www /var/www/conf/htpasswd
$ doas chmod 640 /var/www/conf/htpasswd

$ cat /tmp/h3.conf
server "one.example" {
	listen on 127.0.0.1 port 80
	root "/htdocs/site1"
}
server "two.example" {
	listen on 127.0.0.1 port 80
	root "/htdocs/site2"
	location "/pub/*" {
		directory auto index
	}
	location "/private/*" {
		authenticate with "/conf/htpasswd"
	}
	location "/old" {
		block return 301 "http://two.example/new"
	}
}
$ doas httpd -n -f /tmp/h3.conf
configuration OK
$ doas cp /tmp/h3.conf /etc/httpd.conf
$ doas rcctl reload httpd
httpd(ok)

Try them. The first shows the start of the generated index. Then the private folder without a password, with the right one and with a wrong one:

$ ftp -o - http://two.example/pub/ | grep -E 'title|a.txt'
<title>Index of /pub/</title>
<tr><td><a href="a.txt">a.txt</a></td>
    <td data-o="1793970200">06-Nov-2026 13:03</td><td title="6">6B</td></tr>
const cellVal = (e) => e.getAttribute('title') || e.getAttribute('data-o') ||
$ ftp -o - http://two.example/private/ 2>&1 | head -n 1
ftp: Error retrieving http://two.example/private/: 401 Unauthorized
$ ftp -o - http://ann:secret1@two.example/private/
secret
$ ftp -o - http://ann:wrong@two.example/private/ 2>&1 | head -n 1
ftp: Error retrieving http://ann:wrong@two.example/private/: 401 Unauthorized

A password sent over plain HTTP can be read by anyone on the path, so use this with TLS, which is the next section. Last, the redirect. ftp follows redirects, so look at the raw answer with nc:

$ printf 'HEAD /old HTTP/1.1\r\nHost: two.example\r\nConnection: close\r\n\r\n' | nc -w 2 127.0.0.1 80 | head -n 6
HTTP/1.0 301 Moved Permanently
Connection: close
Content-Length: 510
Content-Type: text/html
Date: Fri, 06 Nov 2026 13:03:25 GMT
Location: http://two.example/new

TLS

For a real site you would get a certificate from a certificate authority. OpenBSD has acme-client(1) in the base system for Let's Encrypt, which needs a public name and port 80 reachable from the internet, so it cannot run in this test. We make a self-signed certificate with openssl instead. The certificate goes in /etc/ssl and the key in /etc/ssl/private:

$ doas openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout /etc/ssl/private/two.example.key -out /etc/ssl/two.example.crt -subj "/CN=two.example" 2>/dev/null
$ doas chmod 600 /etc/ssl/private/two.example.key
$ doas ls -l /etc/ssl/two.example.crt /etc/ssl/private/two.example.key
-rw-------  1 root  wheel  1704 Nov  6 13:03 /etc/ssl/private/two.example.key
-rw-r--r--  1 root  wheel   981 Nov  6 13:03 /etc/ssl/two.example.crt

The chmod matters. openssl leaves the key readable by everyone, and a private key must be readable by root only. httpd reads it as root, before it drops its privileges, so it still works. Now the configuration: port 80 redirects to HTTPS, and port 443 has the tls block. $HTTP_HOST and $REQUEST_URI are filled in by httpd:

$ cat /tmp/h4.conf
server "two.example" {
	listen on 127.0.0.1 port 80
	block return 301 "https://$HTTP_HOST$REQUEST_URI"
}
server "two.example" {
	listen on 127.0.0.1 tls port 443
	tls {
		certificate "/etc/ssl/two.example.crt"
		key "/etc/ssl/private/two.example.key"
	}
	root "/htdocs/site2"
}
$ doas httpd -n -f /tmp/h4.conf
configuration OK
$ doas cp /tmp/h4.conf /etc/httpd.conf
$ doas rcctl reload httpd
httpd(ok)

Fetch over HTTPS. The certificate is self-signed, so by default the client refuses it, which is correct. We tell ftp to trust this one certificate:

$ ftp -o - https://two.example/pub/a.txt 2>&1 | head -n 1
ftp: TLS handshake failure: certificate verification failed: self signed certificate
$ ftp -S cafile=/etc/ssl/two.example.crt -o - https://two.example/pub/a.txt
hello
$ ftp -d -o /dev/null http://two.example/pub/a.txt 2>&1 | grep -E "received 'HTTP|Location"
received 'HTTP/1.0 301 Moved Permanently'
received 'Location: https://two.example/pub/a.txt'
$ echo | openssl s_client -connect 127.0.0.1:443 -servername two.example 2>/dev/null | grep -E "subject=|Protocol"
subject=/CN=two.example
    Protocol  : TLSv1.3

The plain HTTP request is answered with a 301 to the https address, and the handshake uses TLS 1.3. For a public server you would also look at the ciphers and protocols options of the tls block, but the defaults are modern.

CGI programs with slowcgi

httpd does not run programs itself. For dynamic pages it speaks FastCGI, and the base system includes slowcgi(8), a small daemon that runs a plain CGI program for each request and connects it to httpd. Because everything is inside the chroot, the program must be in the chroot, with everything it needs. A static C program needs nothing else. This one prints the header and one line:

$ cat /tmp/hello.c
#include <stdio.h>
int
main(void)
{
	printf("Content-Type: text/plain\r\n\r\n");
	printf("hello from a CGI program\n");
	return 0;
}
$ cc -static -o /tmp/hello.cgi /tmp/hello.c
$ doas install -m 755 /tmp/hello.cgi /var/www/cgi-bin/hello.cgi

$ cat /tmp/h5.conf
server "two.example" {
	listen on 127.0.0.1 port 80
	root "/htdocs/site2"
	location "/cgi-bin/*" {
		fastcgi
		root "/"
	}
}
$ doas httpd -n -f /tmp/h5.conf
configuration OK
$ doas cp /tmp/h5.conf /etc/httpd.conf
$ doas rcctl reload httpd
httpd(ok)
$ doas rcctl enable slowcgi
$ doas rcctl start slowcgi
slowcgi(ok)
$ ftp -o - http://two.example/cgi-bin/hello.cgi
hello from a CGI program

The root "/" inside the location is the chroot's root, so the request path /cgi-bin/hello.cgi finds /var/www/cgi-bin/hello.cgi. Now the mistake everyone makes: a shell script. It looks right, and it does not work, because /bin/sh is not inside the chroot:

$ printf '#!/bin/sh\necho "Content-Type: text/plain"\necho\necho hi\n' | doas tee /var/www/cgi-bin/hi.cgi
#!/bin/sh
echo "Content-Type: text/plain"
echo
echo hi
$ doas chmod 755 /var/www/cgi-bin/hi.cgi
$ ftp -o - http://two.example/cgi-bin/hi.cgi 2>&1 | head -n 1
ftp: Error retrieving http://two.example/cgi-bin/hi.cgi: 500 Internal Server Error
$ doas tail -n 1 /var/log/messages | cut -c1-90
Nov  6 13:03:33 obsd slowcgi[46112]: execve //cgi-bin/hi.cgi: No such file or directory

The browser sees a 500, and the real reason is in the system log: execve fails with No such file or directory, which means the interpreter, not the script, is missing. You can copy a shell and its libraries into /var/www, but a program written in C or Go, built static, is the simpler way.

When it goes wrong

httpd is strict, and its messages are short. A mistake in the configuration is caught by the check, before anything is reloaded, and it names the file and the line:

$ echo 'server "x" { listen on 127.0.0.1 port 80 rooot "/htdocs" }' > /tmp/bad.conf
$ doas httpd -n -f /tmp/bad.conf; echo "exit status $?"
/tmp/bad.conf:1: syntax error
no actions, nothing to do
exit status 1

Always run the check before a reload. The common errors from the clients have meanings. A 404 is a path that does not exist inside the chroot, often a file that exists on the disk, but outside /var/www. A 403 means the file is there and httpd, running as www, may not read it. A 401 is the password. A 500 from a CGI is the program or its interpreter. Make a file unreadable and ask for it, then ask for one that is not there:

$ doas chmod 600 /var/www/htdocs/site2/pub/a.txt
$ ftp -o - http://two.example/pub/a.txt 2>&1 | head -n 1
ftp: Error retrieving http://two.example/pub/a.txt: 403 Forbidden
$ doas chmod 644 /var/www/htdocs/site2/pub/a.txt
$ ftp -o - http://two.example/missing 2>&1 | head -n 1
ftp: Error retrieving http://two.example/missing: 404 Not Found
$ doas tail -n 2 /var/www/logs/access.log | cut -c1-90
two.example 127.0.0.1 - - [06/Nov/2026:13:03:35 +0000] "GET /pub/a.txt HTTP/1.1" 403 0
two.example 127.0.0.1 - - [06/Nov/2026:13:03:35 +0000] "GET /missing HTTP/1.1" 404 0

The access log has the same codes, so a grep of 403 or 500 finds the requests at once. Two more checks. If the server does not start, run httpd -d in a terminal: it stays in the foreground and prints what it is doing, and adding -v makes it say more. And if it starts but nobody can connect, the firewall is the next suspect. Port 80 and 443 need a pass rule in /etc/pf.conf, as the pf guide on this site explains. A rule for it looks like this, and we have not loaded it here:

$ pass in on egress proto tcp to port { 80 443 }

Last, the status of the service, and what is switched on at boot:

$ doas rcctl check httpd
httpd(ok)
$ doas rcctl ls on | grep -E 'httpd|slowcgi'
httpd
slowcgi

That is the whole base system web server. For more, the httpd.conf(5) man page lists every option, such as custom error pages, timeouts and limits, and relayd(8), which is in the base system too, puts a load balancer and TLS front end in front of httpd or any other server.