tailscale/tailscale · error

non-localhost target %q must include a scheme

Error message

non-localhost target %q must include a scheme

What it means

The target had no scheme (no "://" present), so the default scheme was prepended internally (hasScheme == false) — but implicit defaulting is only permitted for loopback hosts. For remote hosts the caller must state the scheme explicitly, because guessing http vs https for an external origin changes proxy semantics.

Source

Thrown at ipn/serve.go:823

	if !slices.Contains(supportedSchemes, u.Scheme) {
		return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes)
	}

	// validate port according to host.
	if u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" {
		// require port for localhost targets
		if u.Port() == "" {
			return "", fmt.Errorf("port required for localhost target %q", target)
		}
	} else {
		validHN := dnsname.ValidHostname(u.Hostname()) == nil
		validIP := net.ParseIP(u.Hostname()) != nil
		if !validHN && !validIP {
			return "", fmt.Errorf("invalid hostname or IP address %q", u.Hostname())
		}
		// require scheme for non-localhost targets
		if !hasScheme {
			return "", fmt.Errorf("non-localhost target %q must include a scheme", target)
		}
	}
	port, err := strconv.ParseUint(u.Port(), 10, 16)
	if err != nil || port == 0 {
		if u.Port() == "" {
			return u.String(), nil // allow no port for remote destinations
		}
		return "", fmt.Errorf("invalid port %q", u.Port())
	}

	u.Host = net.JoinHostPort(u.Hostname(), strconv.Itoa(int(port)))

	return u.String(), nil
}

// TCPs returns an iterator over both background and foreground TCP
// listeners.
//

View on GitHub (pinned to 57c3357fdb)

Solutions

  1. Include the scheme: https://example.com:8080.
  2. Keep local backends on localhost/127.0.0.1 where defaulting (or the bare "3000" shorthand) applies.
  3. Lint serve configs so remote targets always start with scheme://.

Example fix

# before
tailscale serve / example.com:8080
# err: non-localhost target "http://example.com:8080" must include a scheme

# after
tailscale serve / https://example.com:8080
Defensive patterns

Strategy: validation

Validate before calling

needsScheme := !strings.Contains(target, "://") &&
	!strings.HasPrefix(target, "localhost:") && !strings.HasPrefix(target, "127.0.0.1:")
if needsScheme && !isLocalhostShorthand(target) {
	if _, err := netip.ParseAddr(target); err != nil { // bare-port/IP shorthands are local
		return errors.New("remote targets must include scheme://")
	}
}
return ipn.ExpandProxyTargetValue(target, supportedSchemes, defaultScheme)

Prevention

When it happens

Trigger: ipn.ExpandProxyTargetValue("example.com:8080", schemes, "http") — host is non-localhost and hasScheme is false, so after defaulting/validation the function returns this error rather than silently proxying over the assumed scheme.

Common situations: Omitting https:// for an external backend; configs written for localhost targets reused with a remote host; CLI shorthand habits applied to remote origins.

Related errors


AI-assisted analysis of tailscale/tailscale@57c3357fdb (2026-08-18). Data as JSON: /api/errors/5e1332a847a3d7b1. Report an issue: GitHub.