juanfont/headscale · critical
binding to TCP address: %w
Error message
binding to TCP address: %w
What it means
Returned when the main HTTP listener cannot bind h.cfg.Addr (hscontrol/app.go:703-711). With TLS configured it calls tls.Listen("tcp", addr, tlsConfig); otherwise net.ListenConfig.Listen("tcp", addr). This is the primary control-plane address (listen_addr); a failure here is fatal to startup and the wrapped error is the OS bind error.
Source
Thrown at hscontrol/app.go:711
Handler: router,
ReadTimeout: types.HTTPTimeout,
// Long polling should not have any timeout, this is overridden
// further down the chain
WriteTimeout: types.HTTPTimeout,
}
var httpListener net.Listener
if tlsConfig != nil {
httpServer.TLSConfig = tlsConfig
httpListener, err = tls.Listen("tcp", h.cfg.Addr, tlsConfig)
} else {
httpListener, err = new(net.ListenConfig).Listen(context.Background(), "tcp", h.cfg.Addr)
}
if err != nil {
return fmt.Errorf("binding to TCP address: %w", err)
}
errorGroup.Go(func() error { return httpServer.Serve(httpListener) })
log.Info().
Msgf("listening and serving HTTP on: %s", h.cfg.Addr)
// Only start debug/metrics server if address is configured
var debugHTTPServer *http.Server
var debugHTTPListener net.Listener
if h.cfg.MetricsAddr != "" {
debugHTTPListener, err = (&net.ListenConfig{}).Listen(ctx, "tcp", h.cfg.MetricsAddr)
if err != nil {
return fmt.Errorf("binding to TCP address: %w", err)
}
View on GitHub (pinned to 565fd254d0)
Solutions
- Find and stop the current holder of the port: ss -ltnp 'sport = :<port>' or lsof -i :<port>.
- If a reverse proxy terminates TLS, point listen_addr at an internal port (e.g. 127.0.0.1:8080) and proxy to it.
- For privileged ports as non-root, either run as root, use an upstream proxy, or grant the capability: setcap 'cap_net_bind_service=+ep' $(which headscale).
- Confirm the address format is host:port and the host part exists on the machine (ip addr) — use 0.0.0.0:port or [::]:port deliberately.
Example fix
# before listen_addr: 0.0.0.0:443 # nginx already binds 443 -> EADDRINUSE # after (terminate TLS at nginx) listen_addr: 127.0.0.1:8080 server_url: https://headscale.example.org
Defensive patterns
Strategy: validation
Validate before calling
// Pre-flight: confirm the main address is bindable.
func addrBindable(addr string) error {
l, err := net.Listen("tcp", addr)
if err != nil { return err }
return l.Close()
} Try / catch
if err := h.Serve(); err != nil {
if errors.Is(err, syscall.EADDRINUSE) && strings.Contains(err.Error(), "binding to TCP address") {
// port taken: identify with ss -ltnp, stop the holder, restart
}
} Prevention
- Reserve one port per service; document listen_addr per host in config management.
- Behind a reverse proxy, bind headscale to 127.0.0.1 on a high port.
- Use setcap cap_net_bind_service for privileged ports instead of running as root.
When it happens
Trigger: Another process already holds the port (EADDRINUSE) — a second headscale, tailscaled, or a reverse proxy; binding a port below 1024 as non-root (EACCES); listen_addr specifies an IPv6 address on a host with IPv6 disabled (EADDRNOTAVAIL); malformed listen_addr such as a bare port without a colon; firewall/SELinux blocking the bind.
Common situations: headscale behind nginx/caddy that already occupies 443 while listen_addr is still 0.0.0.0:443; systemd restart loop where the old process has not released the socket yet (no SO_REUSEPORT semantics); copying example configs that use :8080 into an environment where 8080 is taken; typo like '8080' instead of ':8080'.
Related errors
- building trusted_proxies middleware: %w
- getting DERPMap: %w
- configuration error: %w
- init state: %w
- DERP server private key and noise private key are the same:
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/35b93c812021b59b.
Report an issue: GitHub.