Contents

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.

image

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.

  1. Separate Disk Volume for Data

    We configure a dedicated disk volume to store data for our web apps.

  2. 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.

  3. 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.

  4. 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.

  5. 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 a Dedicated Server.
  • 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 the Zen 3 architecture.
  • Attached Volume: 10GB
    • 10GB is the minimum volume size.
image

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.

1
ssh-copy-id [email protected]

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”.

1
su -l root

Create the configuration file for the doas by copying from the example.

1
cp /etc/examples/doas.conf /etc/

Open for editing

1
vi /etc/doas.conf

Add nopass option for the :wheel group in doas.conf, so that it looks like this:

1
2
# Allow wheel group by default without requiring password entry
permit keepenv nopass :wheel

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.

1
2
rcctl restart cron # Should fail
doas -u root rcctl restart cron # Should work

The -u options specifies the “user to do as” and it defaults to root. So the following two commands are equivalent.

1
2
doas -u root rcctl restart cron
doas rcctl restart cron

Install essentials

1
2
3
doas pkg_add vim # I chose no_x11-lua version
doas pkg_add curl wget git
doas pkg_add lscpu 

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.

1
sysctl hw.disknames

The 10GB SSD showed up as sd1.

Write a new GPT partition table to the disk using fdisk.

1
doas fdisk -eg sd1

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.

1
doas disklabel -E sd1

Create a partition using the a command.

1
2
3
4
5
6
sd1> a
partition to add: [a] 
offset: [64] 
size: [20971423] 
FS type: [4.2BSD] 
sd1*> 

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 print
  • g is to print in GB
1
2
3
4
5
6
sd1*> p g 
OpenBSD area: 64-20971487; size: 10.0G; free: 0.0G
#                size           offset  fstype [fsize bsize   cpg]
  a:            10.0G               64  4.2BSD   2048 16384     1 
  c:            10.0G                0  unused                    
sd1*> 

Quit and save changes using the q command.

Check the created disklabel and print the sizes in GB.

1
doas disklabel -pg sd1

File System

Create a new file system on the a partition on disk sd1 using newfs.

1
doas newfs sd1a

Mount

Mount this file system using the mount command.

1
2
doas mkdir /var/data
doas mount -v /dev/sd1a /var/data

Explanation:

  • -v is for verbose mode.
  • /dev/sd1a is the “special device” representing the a partition on disk sd1.
  • /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.

1
doas sh -c 'echo "# Data Directory" >> /var/data/README'

Update fstab

Check the DUID (Disk Label Unique Identifier) of this partition

1
sysctl hw.disknames

We get something like this:

1
hw.disknames=sd0:321b80d55546f52c,sd1:123a08f7773f618b,cd0:

So the DUID for disk sd1 is 123a08f7773f618b.

Now, add a new line to /etc/fstab

1
123a08f7773f618b.a /var/data ffs rw,nodev,nosuid 1 2

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

1
doas shutdown -r now

Check if our disk had been properly mounted by checking the file that we created earlier.

1
cat /var/data/README

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.

1
2
3
4
5
# Redirect HTTP (port 80) to port 8080
pass in on vio0 proto tcp from any to any port 80 rdr-to 127.0.0.1 port 8080

# Redirect HTTPS (port 443) to port 8443
pass in on vio0 proto tcp from any to any port 443 rdr-to 127.0.0.1 port 8443

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)
  • SSL/TLS > Overview
    • Full (Strict)

Proxy for our origin server With Proxy Status: Enabled (orange) Cloudflare will act as a reverse proxy for our 5.6.7.8 server. So, when the domain foobar.app is resolved, instead of revealing 5.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.

1
doas pkg_add caddy

Edit /etc/caddy/Caddyfile and add the following at the end.

1
2
3
4
5
6
7
foobar.app {
	reverse_proxy :8898
}

www.foobar.app {
	respond "Foo Bar" 200
}

We are setting up Caddy to:

  • Reverse proxy requests to foobar.app to port 8898. 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 to www.foobar.app.

Change the ports Caddy listen to by editing the http_port and https_port in the Caddyfile.

1
2
http_port 8080
https_port 8443

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
	# bind locally-only by default
	default_bind [::1] 127.0.0.1
	http_port 8080
	https_port 8443

	# admin API endpoint on unix socket
	admin unix//var/caddy/admin.sock|0220

	# don't try to install internal CA to system
	skip_install_trust
}

foobar.app {
	reverse_proxy :8898
}

www.foobar.app {
	respond "Foo Bar" 200
}

Enable and start caddy daemon.

1
2
doas rcctl enable caddy
doas rcctl start caddy 

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.

1
doas useradd -u 898 -g =uid -d /var/empty -s /sbin/nologin _foobar

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.

1
2
id _foobar
getent group _foobar

Create the data directory for our web app.

1
doas mkdir /var/data/foobar

This is inside the external disk that we had set up earlier.

Give ownership of the new directory to _foobar user and group.

1
doas chown _foobar:_foobar /var/data/foobar

Create the script to control our new service at /etc/rc.d/foobar.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#!/bin/ksh

daemon="/usr/local/bin/foobar"
daemon_flags="--port=8898"
daemon_user="_foobar"

. /etc/rc.d/rc.subr

pexp="${daemon} ${daemon_flags}"

rc_bg=YES

rc_start() {
    rc_exec "${daemon} ${daemon_flags}"
}

rc_cmd $1

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.

1
doas chmod a+x /etc/rc.d/foobar

Edit /etc/rc.conf.local and add foobar to the pkg_scripts list. Also set foobar_flags to empty string.

1
2
pkg_scripts=caddy foobar
foobar_flags=""

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:

1
2
3
wget https://go.dev/dl/go1.23.4.openbsd-amd64.tar.gz
doas rm -rf /usr/local/go
doas tar -C /usr/local -xzf go1.23.4.openbsd-amd64.tar.gz

Later on, to upgrade to a new version of Go lang, repeat the same steps with the new gzipped tarball.

1
2
export PATH=$PATH:/usr/local/go/bin
go version

To make the update to $PATH permanent, in the ~/.profile file, append /usr/local/go/bin to PATH.

1
PATH=$PATH:/usr/local/go/bin

Clone the source repo

On the VPS, use ssh-keygen to generate an SSH key pair.

1
ssh-keygen -t ed25519 -C "[email protected]"

Print the public key to the console so it can be copied.

1
cat ~/.ssh/id_ed25519.pub

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.

1
2
cd ~
git clone [email protected]:foobar/foobar.git

Example code

The ~/foobar/go.mod file.

1
2
3
module foobar

go 1.23

The ~/foobar/assets/index.html file.

1
2
3
4
5
6
7
8
9
<!DOCTYPE HTML>
<html lang="en" dir="ltr">
<head>
    <title>foobar</title>
</head>
<body>
<h1>foo, bar, and baz</h1>
</body>
</html>

The ~/foobar/cmd/foobar/main.go file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package main

import (
	"embed"
	"fmt"
	"io/fs"
	"log/slog"
	"net/http"
	"os"
	"strconv"
	"strings"
)

//go:embed assets/*
var embeddedFS embed.FS

func main() {
	mux := http.NewServeMux()

	mux.Handle("GET /version", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = fmt.Fprintln(w, "foobar v0.1")
	}))

	// Serve static assets from the embedded filesystem.
	webDir, err := fs.Sub(embeddedFS, "assets")
	if err != nil {
		slog.Error("foobar: fs sub", "err", err)
	}
	mux.Handle("/", http.FileServer(http.FS(webDir)))

	// Get the address to listen to by parsing command line arguments.
	addr := getAddr(os.Args)
	slog.Info("foobar: starting server", "addr", addr)

	// Start the server.
	err = http.ListenAndServe(addr, mux)
	// TODO: Handle graceful shutdown
	if err != nil {
		slog.Error("foobar: server error", "err", err)
	}
}

// getAddr parses command line args for --port and returns the address to listen on.
// If --port is not the only argument, returns 8898 as default.
//
// TODO: Replace with a parseArgs function that sets app Config.
func getAddr(args []string) string {
	const DefaultPort = 8898

	if len(args) == 2 {
		parts := strings.Split(args[1], "=")
		if len(parts) == 2 {
			name := parts[0]
			if name == "--port" {
				value := parts[1]
				port, err := strconv.Atoi(value)
				if err != nil {
					port = DefaultPort
				}
				return fmt.Sprintf("127.0.0.1:%d", port)
			}
		}
	}

	return fmt.Sprintf("127.0.0.1:%d", DefaultPort)
}

Build the Go web app

Build.

1
2
cd ~/foobar
go build -tags release -o foobar foobar/cmd/foobar

Move the generated binary to /usr/local/bin.

1
doas mv foobar /usr/local/bin/foobar

Before starting the service, try running manually, so we can see errors in the console.

1
doas -u _foobar /usr/local/bin/foobar --port=8898
  • We are running our /usr/local/bin/foobar web app as the user _foobar because we used doas -u _foobar.
  • We are passing in the --port flag to our web app.

Shutdown with Ctrl+C.

Now, start the OpenBSD service with rcctl.

1
doas rcctl start foobar

Enable the service to autostart on server reboot.

1
doas rcctl enable foobar

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.

1
doas shutdown -r now

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/bin/ksh

set -e  # Exit on any command failure
set -u  # Treat unset variables as an error

# Helper function to handle errors
handle_error() {
    echo "Error occurred in script at line $1"
    exit 1
}

# Trap errors
trap 'handle_error $LINENO' ERR

# Define variables
APP_DIR="$HOME/foobar"
APP_BINARY=foobar
DAEMON=foobar
BUILD_CMD="go build -tags release -o $APP_BINARY foobar/cmd/foobar"
INSTALL_PATH=/usr/local/bin/$DAEMON

# Change to application directory
echo "Changing to application directory: $APP_DIR"
cd "$APP_DIR" || exit 1

# Pull latest changes
echo "Pulling latest changes from Git repository"
git pull --ff-only

# Build the application
echo "Building the application"
$BUILD_CMD

# Stop the running service
echo "Stopping the running service"
doas rcctl stop "$DAEMON"

# Replace the old binary
echo "Replacing the old binary"
doas rm -f "$INSTALL_PATH"
doas mv "$APP_BINARY" "$INSTALL_PATH"

# Start the service
echo "Starting the service"
doas rcctl start "$DAEMON"

echo "Deployment completed successfully!"

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.


  1. The app name FooBar and the domain foobar.app are used only as examples. ↩︎