netbirdio/netbird · error

TCP/UDP services must have exactly one target

Error message

TCP/UDP services must have exactly one target

What it means

Returned by validateTCPUDPMode when len(s.Targets) != 1. An L4 service forwards exactly one upstream; there is no load balancing or fallback across multiple targets at the TCP/UDP layer in this model. HTTP services, by contrast, may declare multiple path-routed targets, which is the usual source of the confusion.

Source

Thrown at management/internals/modules/reverseproxy/service/service.go:928

func (s *Service) validateHTTPMode() error {
	if s.Domain == "" {
		return errors.New("service domain is required")
	}
	if s.ListenPort != 0 {
		return errors.New("listen_port is not supported for HTTP services")
	}
	return s.validateHTTPTargets()
}

func (s *Service) validateTCPUDPMode() error {
	if s.Domain == "" {
		return errors.New("domain is required for TCP/UDP services (used for cluster derivation)")
	}
	if s.isAuthEnabled() {
		return errors.New("auth is not supported for TCP/UDP services")
	}
	if len(s.Targets) != 1 {
		return errors.New("TCP/UDP services must have exactly one target")
	}
	if s.Mode == ModeUDP && s.Targets[0].ProxyProtocol {
		return errors.New("proxy_protocol is not supported for UDP services")
	}
	return s.validateL4Target(s.Targets[0])
}

func (s *Service) validateTLSMode() error {
	if s.Domain == "" {
		return errors.New("domain is required for TLS services (used for SNI matching)")
	}
	if s.isAuthEnabled() {
		return errors.New("auth is not supported for TLS services")
	}
	if s.ListenPort == 0 {
		return errors.New("listen_port is required for TLS services")
	}
	if len(s.Targets) != 1 {

View on GitHub (pinned to 93e97f4bf1)

Solutions

  1. Reduce the targets array to exactly one entry for the tcp/udp service.
  2. Expose each upstream as its own service (own listen_port) and load-balance in front of them if needed.
  3. If you need multiple path-routed backends, switch the service to mode http.

Example fix

// before
{ "mode": "tcp", "listen_port": 5432,
  "targets": [ {"target_id": "peer-a", "port": 5432}, {"target_id": "peer-b", "port": 5432} ] }

// after
{ "mode": "tcp", "listen_port": 5432,
  "targets": [ {"target_id": "peer-a", "port": 5432} ] }
Defensive patterns

Strategy: validation

Validate before calling

func checkL4SingleTarget(mode string, targets []Target) error {
	if mode == "tcp" || mode == "udp" {
		if len(targets) != 1 {
			return fmt.Errorf("tcp/udp needs exactly 1 target, got %d", len(targets))
		}
	}
	return nil
}

Type guard

func isL4TargetCountValid(mode string, targets []Target) bool {
	if mode != "tcp" && mode != "udp" {
		return true
	}
	return len(targets) == 1
}

Try / catch

if err := svc.Validate(); err != nil {
	if strings.Contains(err.Error(), "exactly one target") {
		return respondBadRequest(errors.New("split into one service per upstream"))
	}
	return respondBadRequest(err)
}

Prevention

When it happens

Trigger: Submitting a tcp/udp service with 0 targets (also caught earlier by Validate's 'at least one target' check only when 0) or with 2+ targets, e.g. a primary and a backup upstream.

Common situations: Reusing an HTTP multi-target (path-based routing) template for an L4 service. Trying to approximate high availability by listing two database replicas as targets. Splitting traffic with the expectation the proxy round-robins at L4.

Related errors


AI-assisted analysis of netbirdio/netbird@93e97f4bf1 (2026-08-16). Data as JSON: /api/errors/8c2f6820be4e27e4. Report an issue: GitHub.