tailscale/tailscale · error

can not load x509 key pair for hostname %q: %w

Error message

can not load x509 key pair for hostname %q: %w

What it means

In --certmode=manual, NewManualCertManager builds <certdir>/<keyname>.crt and .key (keyname = --hostname with unsafe characters stripped by regex) and calls tls.LoadX509KeyPair on them. This error wraps that failure after the only recovery path: if the files don't exist AND the hostname parses as an IP, a self-signed cert is generated; anything else (missing files for a DNS name, malformed PEM, key/cert mismatch, unreadable files) surfaces here.

Source

Thrown at cmd/derper/cert.go:131

	noHostname bool   // whether hostname is an IP address
}

// NewManualCertManager returns a cert provider which read certificate by given hostname on create.
func NewManualCertManager(certdir, hostname string) (certProvider, error) {
	keyname := unsafeHostnameCharacters.ReplaceAllString(hostname, "")
	crtPath := filepath.Join(certdir, keyname+".crt")
	keyPath := filepath.Join(certdir, keyname+".key")
	cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
	hostnameIP := net.ParseIP(hostname) // or nil if hostname isn't an IP address
	if err != nil {
		// If the hostname is an IP address, automatically create a
		// self-signed certificate for it.
		var certp *tls.Certificate
		if os.IsNotExist(err) && hostnameIP != nil {
			certp, err = createSelfSignedIPCert(crtPath, keyPath, hostname)
		}
		if err != nil {
			return nil, fmt.Errorf("can not load x509 key pair for hostname %q: %w", keyname, err)
		}
		cert = *certp
	}
	// ensure hostname matches with the certificate
	x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
	if err != nil {
		return nil, fmt.Errorf("can not load cert: %w", err)
	}
	if err := x509Cert.VerifyHostname(hostname); err != nil {
		return nil, fmt.Errorf("cert invalid for hostname %q: %w", hostname, err)
	}
	if hostnameIP != nil {
		// If the hostname is an IP address, print out information on how to
		// confgure this in the derpmap.
		dn := &tailcfg.DERPNode{
			Name:     "custom",
			RegionID: 900,
			HostName: hostname,

View on GitHub (pinned to cfe32b8be6)

Solutions

  1. Name the files exactly <hostname>.crt and <hostname>.key inside --certdir (characters like * are stripped from the name)
  2. Verify the pair loads: openssl x509 -in <hostname>.crt -noout && openssl pkey -in <hostname>.key -noout, and check modulus match
  3. Point --certdir at the directory actually containing the files
  4. If the hostname is an IP, ensure --certdir is writable so the self-signed cert can be created
  5. Or switch to --certmode=letsencrypt to avoid file management

Example fix

# before: derper --certmode=manual --certdir=/letsencrypt/live/derp.example.com
#         (dir holds fullchain.pem / privkey.pem)
# after:  cp fullchain.pem /certs/derp.example.com.crt
#         cp privkey.pem  /certs/derp.example.com.key
#         derper --certmode=manual --certdir=/certs --hostname=derp.example.com
Defensive patterns

Strategy: validation

Validate before calling

// Pre-flight a manual cert dir before starting derper:
func checkManualCert(dir, hostname string) error {
	key := regexp.MustCompile(`[^a-zA-Z0-9.-]`).ReplaceAllString(hostname, "")
	if _, err := tls.LoadX509KeyPair(filepath.Join(dir, key+".crt"), filepath.Join(dir, key+".key")); err != nil {
		return fmt.Errorf("manual cert pair invalid: %w", err)
	}
	return nil
}

Prevention

When it happens

Trigger: Cert files not named after --hostname (e.g. fullchain.pem instead of derp.example.com.crt); --certdir pointing at the wrong directory; malformed or truncated PEM; private key not matching the certificate; file permissions; IP hostname where self-signing also failed (e.g. unwritable certdir).

Common situations: Following Let's Encrypt layouts (cert.pem/privkey.pem) instead of derper's expected naming; copying certs with the wrong case or including '*.' which gets stripped; certdir read-only so the IP self-sign fallback fails.

Related errors


AI-assisted analysis of tailscale/tailscale@cfe32b8be6 (2026-08-15). Data as JSON: /api/errors/5b9f1f4670152546. Report an issue: GitHub.