How to Host Multiple Go Lang Web Apps on a VPS
With help from the OpenBSD OS and the Caddy Server
Overview
In this technical note, we’ll walk through creating a virtual private server (VPS) and setting up the OpenBSD operating system with the Caddy server to deploy multiple web applications.
By the end, we will have a robust and secure environment to host web apps like FooBar and others. Let’s begin by exploring how everything fits together.

Multiple Go lang web apps hosted on a VPS with OpenBSD, PF and Caddy
The steps for hosting one app can easily be repeated to support additional apps.
FooBar1 is a Go web application, but the method described works for any app that communicates via HTTP on a TCP port, regardless of the programming language.
Let’s start with a typical request flow when a browser accesses https://foobar.app
:
- The domain
foobar.app
resolves to an IP address managed by Cloudflare. - Cloudflare serves as a reverse proxy for foobar.app.
- It redirects HTTP traffic to HTTPS and forwards HTTPS traffic to our VPS.
- Our VPS is the origin server for
foobar.app
.
We use Cloudflare’s Strict HTTPS mode, ensuring all connections—between the browser, Cloudflare, and our VPS—are encrypted.
On the VPS, we install OpenBSD, which includes a built-in firewall called pf.
Unlike Linux-based systems (e.g., Debian, Ubuntu, CentOS), OpenBSD is a complete, cohesive operating system with a focus on security and code quality. Since its inception in 1996, OpenBSD has had only two remote vulnerabilities in its default installation, the most recent over 22 years ago. For more fascinating details, check out why-OpenBSD.rocks.
Let’s look at the key steps after the operating system is installed.
-
Separate Disk Volume for Data
We configure a dedicated disk volume to store data for our web apps.
-
Configure the pf Firewall
Using pf, we forward TCP traffic on ports 80 and 443 from the public interface to ports 8080 and 8443 on the loopback interface. This isolates the web server, ensuring it listens only locally, reducing the attack surface and enhancing security.
-
Install and Set Up Caddy Server
OpenBSD now supports the excellent Caddy Server, written in Go. Caddy simplifies managing TLS certificates by automatically provisioning and renewing them. Even with dozens of web apps listening on different local ports, Caddy (listening on ports 8080 and 8443) is the only server exposed to the Internet, thanks to our pf rules. Acting as a reverse proxy, Caddy maps each domain name to its corresponding app’s local port via the Caddyfile.
-
Configure Services for Web Apps
We configure OpenBSD services for each app to enable starting, stopping, and automatic launching on server boot via the rcctl command.
-
Build and Deploy Web Apps
To deploy an app, we clone its Git repository, build the binary, copy it to the appropriate location, and start its service. For redeployments, we’ll use a simple ksh script to streamline the process.
By following these steps, you’ll have a secure, efficient setup for hosting multiple web apps on your OpenBSD VPS with Caddy.
Create a VPS
Go to your infrastructure provider’s dashboard and create a new server (a VPS).
Choosing an Infrastructure (Cloud) Provider I used Hetzner Cloud, and this log note is based on that experience. However, since creating a new server, attaching a disk volume, and accessing a shell are similar across cloud providers, you can follow this guide with any provider.
If you haven’t used Hetzner before, you can get €20/$20 in free credit on your first month by following my referral link to Hetzner Cloud.
Hetzner Cloud Dashboard > Project > Add Server
- Location:
Hillsboro, Oregon
- Hetzner has limited data center locations compared to other providers. As of December 2024, they have locations in Falkenstein and Nuremberg (Germany), Helsinki (Finland), Hillsboro (USA West Coast), Ashburn (USA East Coast), and finally Singapore (which is markedly more expensive than other locations).
- Image:
Debian 12
- Hetzner doesn’t have OpenBSD as an option in the “Add Server” screen, but once the server is created, mounting the latest stable OpenBSD image is allowed.
- VPS Type:
Dedicated vCPU
- A
Shared vCPU
option is also available and is much cheaper. However, for consistent performance from a VPS, a Dedicated vCPU offers great value for money, falling just short of getting aDedicated Server
.
- A
- VPS Model:
CCX13
( 2 vCPU | 8GB RAM | 80GB SSD | 1TB Egress).- Egress is data going out of the server, and Ingress (data coming into the server) is totally free.
- As of December 2024, Hetzner provisions
AMD EPYC
series CPUs for this line of virtual private servers. - The CPU model on the VPS I received was:
AMD EPYC 7003 (Milan)
built on theZen 3
architecture.
- Attached Volume:
10GB
10GB
is the minimum volume size.

A CCX13-type virtual private server with dedicated vCPUs was deployed on Hetzner Cloud.
Install OpenBSD
Run Installer
Mount the OpenBSD ISO image
and reboot the server.
- Server > ISO Images >
OpenBSD 7.6 (amd64)
- Server > Console > Reboot (with the button that sends
Ctrl+Alt+Del
)
After rebooting, the OpenBSD installer program starts running.
- What to do:
(I)nstall
- Keyboard layout:
Default
(or your preferred layout)
Set up Network
- System hostname:
foobar
(your preferred name) - Network interface:
vio0
- IPV4:
autoconfig
- IPV6:
autoconfig
The vio interface Hetzner implements its virtual servers using KVM as the backend hypervisor and QEMU as the frontend emulator. The OpenBSD VirtIO device driver supports the network interface exposed by KVM/QEMU to the VPS.
Root, User, Time
- Set
root
password - Start sshd(8) by default:
Yes
- Start X Windows:
No
- Change the default console to com0:
Yes
- Set up a user:
nalaka
(only if you have the same name as me) - Allow root ssh login:
No
- Time zone:
US/Pacific
(or wherever you are)
Disk, Reboot
- Which disk:
sd0
- Encrypt the root disk with a passphrase:
No
- Use whole disk MBR, whole disk GPT, or edit:
GPT
- Use auto layout, edit, custom:
auto
- Install these sets from:
cd0
- Pathname to the sets:
7.6/amd64
- leave all sets selected - Halt
Detach the ISO image. Power off, then power on the server.
Initial Setup
Say,
- The server’s IP address is
5.6.7.8
- The regular user’s name is
nalaka
Copy SSH Key
Use the ssh-copy-id
command on the work computer to copy your SSH public key to the server.
|
|
Connect to the server via SSH.
|
|
Configure doas
The OpenBSD doas command is somewhat similar to the sudo
command in Linux.
The word doas
can be thought of as a concatenation of the first two words in the phrase “do as another user”.
SSH in to our VM as the regular user. We can’t SSH as root because we have disabled that when we installed OpenBSD.
|
|
After accessing the server, we can use the su command to switch from our regular
user identity (a user named nalaka) to the all-powerful root
user. The word su
can be thought of as a concatenation
of the first two words in the phrase “substitute user identity”.
|
|
Create the configuration file for the doas
by copying from the example.
|
|
Open for editing
|
|
Add nopass
option for the :wheel
group in doas.conf
, so that it looks like this:
|
|
Now we can use the doas
command to “execute a command as another user” without entering that user’s password.
Exit the root shell with exit
and check if doas
works.
|
|
The -u
options specifies the “user to do as” and it defaults to root
. So the following two commands are equivalent.
|
|
Install essentials
|
|
Set up external disk
Using an external disk to store our web app’s data allows us to manage the operating system and our data separately.
Partition Table
List the disk names known to the OpenBSD Kernel using sysctl.
|
|
The 10GB SSD showed up as sd1
.
Write a new GPT partition table to the disk using fdisk.
|
|
This commands initializes a new GPT partition table on the disk sd1
and opens the fdisk
interactive editor.
- Use the
print
command to see the partition table that will be created. - Use
quit
to save changes and exit.
Partition
Open the disklabel editor on sd1
.
|
|
Create a partition using the a
command.
|
|
The label for the new partition is a
and it takes all available space.
For file system type we choose the default 4.2BSD FFS2
.
Use p g
to print the partition table.
p
is for printg
is to print in GB
|
|
Quit and save changes using the q
command.
Check the created disklabel
and print the sizes in GB.
|
|
File System
Create a new file system on the a
partition on disk sd1
using newfs.
|
|
Mount
Mount this file system using the mount command.
|
|
Explanation:
-v
is for verbose mode./dev/sd1a
is the “special device” representing thea
partition on disksd1
./var/data
is the node in the file system tree where that special device will be mounted.
To check all is well, create a file in this new file system.
|
|
Update fstab
Check the DUID
(Disk Label Unique Identifier) of this partition
|
|
We get something like this:
|
|
So the DUID
for disk sd1
is 123a08f7773f618b
.
Now, add a new line to /etc/fstab
|
|
This line describes where and how our disk should be mounted on the file system tree.
123a08f7773f618b.a
. The “block special device” to mount, in this case, the a
partition on the disk identified by
the DUID (Disk Label Unique ID) 123a08f7773f618b
(corresponding to the device sd1
).
/var/data
. The mount point in the file system tree where the partition will be accessible.
ffs
. The type of file system to use. ffs
refers to the 4.2BSD Fast File System 2,
which is the default file system for OpenBSD.
rw,nodev,nosuid
are the mount options.
rw
. Enables read-write access.nodev
. Prevents the interpretation of special device files, meaning it is not possible to use mknod to create device files or access devices from this file system.nosuid
. Disables the ability to set the set-user-identifier (setuid) or set-group-identifier (setgid) bits, preventing privilege escalation through executables on this file system.
1 2
File system backup and check options.
1
. The file system backup frequency (in days) for use by the dump command.2
. The pass number for the fsck command. This determines the order in which file systems are checked. The root file system is checked first (pass 1), and this disk will be checked during the second pass.
Reboot
|
|
Check if our disk had been properly mounted by checking the file that we created earlier.
|
|
Setup Firewall
OpenBSD includes a built-in firewall called PF, which is enabled by default.
By default, it “drops packets” arriving at the HTTP (80) and HTTPS (443) ports of the
Internet facing network interface, vio0
.
In the next step, we will configure Caddy to serve multiple web applications. Caddy will listen on ports 8080 (HTTP) and 8443 (HTTPS) on the loopback network interface.
To make this work, we need to redirect TCP packets arriving at ports 80 and 443 on vio0
to ports 8080 and 8443, respectively, on the loopback interface.
Add to the end of /etc/pf.conf
, add the following lines.
|
|
Reload pf config with doas pfctl -f /etc/pf.conf
Add DNS Records
Go to the domain registrar’s dashboard and add DNS records for each domain you want to serve.
For example, in Cloudflare: Console > foobar.app
- DNS > Records > Add
- Type:
A
- Name:
*
- IPv4 Address:
5.6.7.8
- Proxy Status:
Enabled (orange)
- Type:
- SSL/TLS > Overview
Full (Strict)
Proxy for our origin server With Proxy Status:
Enabled (orange)
Cloudflare will act as a reverse proxy for our5.6.7.8
server. So, when the domainfoobar.app
is resolved, instead of revealing5.6.7.8
, Cloudflare will show one of its IP addresses.With
Full (Strict)
SSL/TLS encryption mode, both “Browser - Cloudflare” and “Cloudflare - Origin Server” connections are encrypted.
To serve the www
subdomain as well, we can add another DNS record.
- Type:
A
- Name:
www
- IPv4 Address:
5.6.7.8
- Proxy Status:
Enabled (orange)
Setup Caddy
Install Caddy on OpenBSD.
|
|
Edit /etc/caddy/Caddyfile
and add the following at the end.
|
|
We are setting up Caddy to:
- Reverse proxy requests to
foobar.app
to port8898
. Later on, we will set up our Go web app to run and listen at this port. - Respond with the text “Foo Bar” and a
200
status code for requests towww.foobar.app
.
Change the ports Caddy listen to by editing the http_port
and https_port
in the Caddyfile
.
|
|
Caddyfile We had already set a
pf
(OpenBSD packet filter) rule to forward incoming HTTP (80) and HTTPS (443) packets to ports 8080 and 8443 respectively.
Here is the full Caddyfile
for reference.
|
|
Enable and start caddy
daemon.
|
|
Now when we visit https://www.foobar.app
we should get the text “Foo Bar” and a 200
status code.
If http://www.foobar.app
is visited, the request should be redirected to https://www.foobar.app
automatically.
After the next sections, when we run our Go web app at port 8898
we will be able to see it at https://foobar.app.
Set up the Service
The next step is to configure an OpenBSD service for our web app. This allows us to start and stop the web app using the rcctl command and ensures the web app automatically starts on server boot.
First, we will create a dedicated system user _foobar
to run the Go app.
|
|
The user ID 898 is chosen arbitrarily and such that it is not already used in the system.
Check the newly created user and group.
|
|
Create the data directory for our web app.
|
|
This is inside the external disk that we had set up earlier.
Give ownership of the new directory to _foobar
user and group.
|
|
Create the script to control our new service at /etc/rc.d/foobar
.
|
|
Later on, we will build our Go web app and place the binary at /usr/local/bin/foobar
.
We are passing the flag --port=8898
to the Go app so it knows which port to start the server on.
Make thefoobar
service control script executable.
|
|
Edit /etc/rc.conf.local
and add foobar
to the pkg_scripts
list.
Also set foobar_flags
to empty string.
|
|
Now the foobar
service is set up and enabled.
Set up the Build
Install Go
We can install the Go lang toolchain by downloading the correct gzipped tarball from the official Go website.
For example:
|
|
Later on, to upgrade to a new version of Go lang, repeat the same steps with the new gzipped tarball.
|
|
To make the update to $PATH
permanent, in the ~/.profile
file, append /usr/local/go/bin
to PATH
.
|
|
Clone the source repo
On the VPS, use ssh-keygen to generate an SSH key pair.
|
|
Print the public key to the console so it can be copied.
|
|
Go to the Git repository hosting provider’s dashboard and add the copied SSH public key- e.g. in GitHub: Settings > Access > SSH and GPG keys > Add
Clone the source repository.
|
|
Example code
The ~/foobar/go.mod
file.
|
|
The ~/foobar/assets/index.html
file.
|
|
The ~/foobar/cmd/foobar/main.go
file.
|
|
Build the Go web app
Build.
|
|
Move the generated binary to /usr/local/bin
.
|
|
Before starting the service, try running manually, so we can see errors in the console.
|
|
- We are running our
/usr/local/bin/foobar
web app as the user_foobar
because we useddoas -u _foobar
. - We are passing in the
--port
flag to our web app.
Shutdown with Ctrl+C
.
Now, start the OpenBSD service with rcctl
.
|
|
Enable the service to autostart on server reboot.
|
|
Now when we visit https://foobar.app
we should see our Go web app.
Check that all is well
Restart server and check if everything is still working as expected.
|
|
Deploy a new version
We just need to pull the latest code from the remote Git repository, build, stop the foobar
service,
replace the foobar
binary, and start the foobar
service.
Here’s a ~/foobar/deploy.ksh
script that will carry out these steps with some error reporting.
|
|
Multiple web apps
Serving multiple web apps is as simple as adding a new domain to the Caddyfile
and
setting up a new OpenBSD service
to run that web app under.
These web apps do not need to be written in Go lang. For example, they could be written in languages like Rust, Zig, Swift, C++, or even in C. In fact, we could write the web app in any language as long as it listens on a TCP port and talks HTTP with connecting clients.
-
The app name FooBar and the domain foobar.app are used only as examples. ↩︎