WebSockets, Caddy, and Go (Oh My!)

A no-nonsense quickstart guide to building a WebSockets server.

TL;DR:

  • Go
    • Write server, build, install to /usr/local/bin
  • User
    • Create dedicated user for socket & working directory
  • Service
    • Add systemd service to create a socket, which binds to server
  • Proxy
    • Configure Caddy for WebSockets
  • Client
    • Add a JavaScript client to a web page

Suggestions/Assumptions

  • Debian Trixie
  • Go 1.25.0+
  • Caddy 2.10.2+

Go - make a working directory and start a project

go mod init wsd
go get github.com/coder/websocket@latest

Create a main.go file

package main

import (
	"context"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"time"

	"github.com/coder/websocket"
	"github.com/coder/websocket/wsjson"
)

const (
	socketPath  = "/run/wsd/wsd.sock"
	endpoint    = "/ws"
	maxMsgBytes = 64 << 10 // 64 KiB safety cap
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc(endpoint, wsHandler)
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })

	_ = os.Remove(socketPath)
	ln, err := net.Listen("unix", socketPath)
	if err != nil { log.Fatal(err) }
	if err := os.Chmod(socketPath, 0o660); err != nil { log.Fatal(err) }

	s := &http.Server{ Handler: mux, ReadHeaderTimeout: 5 * time.Second }
	log.Printf("listening UDS %s", socketPath)
	log.Fatal(s.Serve(ln))
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
	c, err := websocket.Accept(w, r, nil) // cross-origin denied by default
	if err != nil {
		return
	}
	defer c.Close(websocket.StatusNormalClosure, "")

	ctx := context.Background() // say hi
	_ = wsjson.Write(ctx, c, map[string]any{
		"type": "hello",
		"ts":   time.Now().Unix(),
	})

	go func() {
		for {
			rctx, cancel := context.WithTimeout(ctx, 20*time.Second) // timeout
			typ, rd, err := c.Reader(rctx)
			cancel()
			if err != nil {
				return
			}
			limited := io.LimitReader(rd, maxMsgBytes+1) // max size
			buf, err := io.ReadAll(limited)
			if err != nil || int64(len(buf)) > maxMsgBytes {
				c.Close(websocket.StatusPolicyViolation, "message too large")
				return
			}
			wctx, cancelW := context.WithTimeout(ctx, 5*time.Second) // echo
			w, err := c.Writer(wctx, typ)
			if err == nil {
				_, _ = w.Write(buf)
				_ = w.Close()
			}
			cancelW()
		}
	}()

	t := time.NewTicker(30 * time.Second) // heartbeat
	defer t.Stop()
	for {
		select {
		case <-t.C:
			pctx, cancel := context.WithTimeout(ctx, 5*time.Second)
			err := c.Ping(pctx)
			cancel()
			if err != nil {
				return
			}
		case <-r.Context().Done():
			return
		}
	}
}

Build & install

go build -trimpath -ldflags "-s -w" -o wsd
sudo install -D -o root -g root -m 0755 wsd /usr/local/bin/

User - create a user to own the socket

sudo useradd -r -d /var/lib/wsd wsd
sudo mkdir /var/lib/wsd
sudo chown wsd:wsd /var/lib/wsd
sudo chmod 750 /var/lib/wsd

Socket… sock… sock it to me…

# /etc/systemd/system/wsd.service

[Unit]
Description=Minimal WebSocket daemon
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=wsd
Group=wsd

RuntimeDirectory=wsd
RuntimeDirectoryMode=0750
WorkingDirectory=/run/wsd
UMask=007

ExecStartPre=/usr/bin/rm -f /run/wsd/wsd.sock
ExecStart=/usr/local/bin/wsd

Restart=always
RestartSec=2s
TimeoutStopSec=5s

# Harden
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
PrivateNetwork=true
RestrictAddressFamilies=AF_UNIX
IPAddressDeny=any

# Isolate
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/run/wsd
PrivateTmp=true
PrivateDevices=true
PrivateUsers=true
PrivateMounts=true

# Restrict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictRealtime=true
RemoveIPC=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service

# Limit
ProcSubset=pid
ProtectProc=invisible

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now wsd

Proxy

It doesn’t get any easier than Caddy, so that’s my example here, but HAProxy is good, too.

sudo apt-get install caddy
# /etc/caddy/Caddyfile
scotty.nyc {
    @ws path /ws*
	handle @ws {
		reverse_proxy unix//run/ws/wd.sock
	}
	root * /var/www
	file_server
	log {
		output file /var/log/caddy.log
		format json
	}
}
caddy validate -c /etc/caddy/Caddyfile
caddy fmt /etc/caddy/Caddyfile --overwrite
sudo systemctl restart caddy

Client - Add something like this to your page

<script type="module">
  const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
  let backoff = 500;
  (function connect() {
    const ws = new WebSocket(url);
    ws.onopen    = () => (backoff = 500);
    ws.onmessage = e  => console.log('ws:', e.data);
    ws.onerror   = () => ws.close();
    ws.onclose   = () => setTimeout(connect, backoff = Math.min(backoff * 2, 8000));
  })();
</script>

Done

That’s it. Now you can get to work.

Appendix: WebSocket Go Libraries

For my use-case (~500-1000 concurrent users, low data, low latency), I chose coder/websocket for a balance of speed and memory management, but there are plenty of other excellent libraries if you prefer:

  • coder/websocket - zero alloc, zero deps, dead simple.
  • lxzan/gws - The fastest, at the cost of RAM.
  • lesismal/nbio - Can do 1M+ concurrent connections, but bigger framework.
  • gorilla/websocket - Reliable and well-documented.
  • gobwas/ws - Zero copy, super light. Control your own buffers.
  • centrifugal/centrifuge - Full messaging platform, not just WS. Out-of-the-box solution.

#EOF