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

  1. Find and stop the current holder of the port: ss -ltnp 'sport = :<port>' or lsof -i :<port>.
  2. If a reverse proxy terminates TLS, point listen_addr at an internal port (e.g. 127.0.0.1:8080) and proxy to it.
  3. 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).
  4. 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

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


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/35b93c812021b59b. Report an issue: GitHub.