How frp Service Health Checking Works with TCP and HTTP Probes

frp implements client-side health checking that periodically probes local services using TCP connection attempts or HTTP requests to ensure traffic is only forwarded to healthy backends.

frp (Fast Reverse Proxy) includes a robust service health checking mechanism that runs on the client side to monitor local service availability. When enabled in frpc.toml, the frp client validates that backend services respond to configured probes before registering them with the server, automatically removing unhealthy proxies from the forwarding pool.

Health Check Configuration Structure

Health check parameters are defined in the HealthCheckConfig struct located in pkg/config/v1/proxy.go. This configuration supports both TCP and HTTP probe types with customizable intervals, timeouts, and failure thresholds.

// https://github.com/fatedier/frp/blob/dev/pkg/config/v1/proxy.go#L73-L97
type HealthCheckConfig struct {
    // Type of probe: "tcp" or "http". Empty disables health checking.
    Type            string `json:"type"`          // tcp | http
    TimeoutSeconds  int    `json:"timeoutSeconds,omitempty"`   // default 3 s
    MaxFailed       int    `json:"maxFailed,omitempty"`        // default 1
    IntervalSeconds int    `json:"intervalSeconds"`           // default 10 s
    Path            string `json:"path,omitempty"`            // HTTP path
    HTTPHeaders     []HTTPHeader `json:"httpHeaders,omitempty"`
}

The Type field determines the probe mechanism: "tcp" performs a simple connection test, while "http" sends an HTTP GET request and validates the response status. When Type is empty, health checking is disabled for that proxy.

Monitor Creation and Lifecycle

When the frp client initializes a proxy wrapper in client/proxy/proxy_wrapper.go, it inspects the proxy's HealthCheck configuration. If a valid type is specified and the proxy defines a localPort, the wrapper instantiates a health monitor:

// https://github.com/fatedier/frp/blob/dev/client/proxy/proxy_wrapper.go#L18-L24
if baseInfo.HealthCheck.Type != "" && baseInfo.LocalPort > 0 {
    pw.health = 1 // start in *failed* state
    addr := net.JoinHostPort(baseInfo.LocalIP, strconv.Itoa(baseInfo.LocalPort))
    pw.monitor = health.NewMonitor(pw.ctx, baseInfo.HealthCheck, addr,
        pw.statusNormalCallback, pw.statusFailedCallback)
    xl.Tracef("enable health check monitor")
}

The monitor initializes with the health state set to 1 (failed), ensuring the proxy only registers after the first successful probe. The monitor runs in a dedicated goroutine (pw.monitor.Start()) once the proxy wrapper starts, executing checks at the configured IntervalSeconds frequency.

Probe Implementation Details

The core probe logic resides in client/health/health.go, where the Monitor type executes type-specific checks via doCheck().

TCP Health Checks

For TCP probes, the monitor attempts to establish a raw TCP connection to the target address using net.DialContext with the configured timeout:

// https://github.com/fatedier/frp/blob/dev/client/health/health.go#L52-L66
func (monitor *Monitor) doTCPCheck(ctx context.Context) error {
    if monitor.addr == "" {
        return nil // no address → treat as success
    }
    var d net.Dialer
    conn, err := d.DialContext(ctx, "tcp", monitor.addr)
    if err != nil {
        return err                // connection failed → health check fails
    }
    conn.Close()
    return nil                    // success
}

Any connection error results in a failed health check. Successfully opening and immediately closing the TCP socket indicates the service is healthy.

HTTP Health Checks

HTTP probes construct a GET request to the target address combined with the optional Path field, injecting custom headers from the HTTPHeaders configuration:

// https://github.com/fatedier/frp/blob/dev/client/health/health.go#L67-L84
func (monitor *Monitor) doHTTPCheck(ctx context.Context) error {
    req, err := http.NewRequestWithContext(ctx, "GET", monitor.url, nil)
    if err != nil { return err }
    req.Header = monitor.header
    req.Host = monitor.header.Get("Host")
    resp, err := http.DefaultClient.Do(req)
    if err != nil { return err }
    defer resp.Body.Close()
    _, _ = io.Copy(io.Discard, resp.Body) // consume body
    if resp.StatusCode/100 != 2 {
        return fmt.Errorf("do http health check, StatusCode is [%d] not 2xx", resp.StatusCode)
    }
    return nil
}

The probe fails if the request errors or returns a non-2xx status code. The response body is fully consumed to ensure connection reuse, and the Host header is explicitly set from the configured headers to support virtual hosting scenarios.

Health State Management and Proxy Control

The monitor reports probe results via callbacks defined in client/proxy/proxy_wrapper.go, which update an atomic health flag and notify a control channel:

// https://github.com/fatedier/frp/blob/dev/client/proxy/proxy_wrapper.go#L36-L48
func (pw *Wrapper) statusNormalCallback() {
    atomic.StoreUint32(&pw.health, 0)
    pw.healthNotifyCh <- struct{}{} // non-blocking send
    xl.Infof("health check success")
}
func (pw *Wrapper) statusFailedCallback() {
    atomic.StoreUint32(&pw.health, 1)
    pw.healthNotifyCh <- struct{}{}
    xl.Infof("health check failed")
}

A dedicated checkWorker goroutine monitors these notifications and manages the proxy lifecycle based on the health state stored at pw.health:

  • Healthy (0): If the proxy is not currently registered, the wrapper sends a NewProxy message to the server to activate forwarding.
  • Unhealthy (1): If the proxy is currently running, the wrapper marks it as check-failed, closes the proxy connection, and the server stops forwarding traffic to this backend.

This mechanism ensures that frp acts as a gatekeeper: proxies remain active only while their local services successfully respond to health probes.

Server-Side Liveness Endpoint

Note that the frp server exposes a simple liveness endpoint at /healthz (defined in server/service.go), which returns HTTP 200 for dashboard and external monitoring purposes. This is unrelated to the per-proxy health checking described above and does not validate individual proxy health states.

Configuration Examples

TCP Health Check for a Redis Service

Probe a local Redis instance every 5 seconds, requiring a successful TCP connection within 2 seconds:

[[proxies]]
name = "redis"
type = "tcp"
local_ip = "127.0.0.1"
local_port = 6379
remote_port = 6379

healthCheck.type = "tcp"
healthCheck.intervalSeconds = 5
healthCheck.timeoutSeconds = 2
healthCheck.maxFailed = 1

HTTP Health Check with Custom Headers

Validate a web service by querying the /status endpoint with a custom Host header:

[[proxies]]
name = "web"
type = "http"
local_port = 8080
remote_port = 80

healthCheck.type = "http"
healthCheck.path = "/status"
healthCheck.intervalSeconds = 10
healthCheck.timeoutSeconds = 3
healthCheck.httpHeaders = [{name = "Host", value = "myapp.local"}]

Summary

  • Client-side execution: Health checks run on the frp client, not the server, directly probing the local service behind the proxy.
  • Dual probe support: TCP checks verify layer-4 connectivity via net.DialContext, while HTTP checks validate layer-7 application health requiring 2xx responses.
  • Automatic lifecycle management: Proxies start in a failed state and only register with the server after the first successful probe; consecutive failures trigger automatic deregistration and connection closure.
  • Configurable thresholds: Administrators control check intervals, timeouts, and maximum consecutive failures via HealthCheckConfig in frpc.toml.
  • Atomic state tracking: Health status is maintained as a uint32 flag (0=healthy, 1=failed) with non-blocking channel notifications to prevent race conditions during state transitions.

Frequently Asked Questions

Does frp support health checking on the server side?

No. According to the fatedier/frp source code, health checking is implemented exclusively on the client side within the client/health and client/proxy packages. The server only provides a simple /healthz endpoint for its own process liveness, which does not monitor individual proxy health states.

What happens when a health check fails?

When statusFailedCallback() executes in client/proxy/proxy_wrapper.go, the proxy's health flag is set to 1 and the checkWorker closes the active proxy connection. The server stops forwarding traffic to this backend. Once the probe succeeds again, statusNormalCallback() sets the flag to 0 and re-registers the proxy with a NewProxy message.

Can I use custom HTTP headers in health checks?

Yes. The HealthCheckConfig struct includes an HTTPHeaders field that accepts an array of header objects. These headers are injected into the GET request in doHTTPCheck(), including support for setting the Host header to accommodate virtual hosting or API gateway requirements.

What is the default timeout for health checks?

The default TimeoutSeconds is 3 seconds if not specified in the configuration. You can override this per proxy using the healthCheck.timeoutSeconds setting in your frpc.toml file.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →