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