# How frp Service Health Checking Works with TCP and HTTP Probes

> Discover how frp's service health checking uses TCP and HTTP probes to ensure traffic only goes to healthy local services. Learn about active probing for reliable backend connections.

- Repository: [fatedier/frp](https://github.com/fatedier/frp)
- Tags: internals
- Published: 2026-02-26

---

**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`](https://github.com/fatedier/frp/blob/main/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`](https://github.com/fatedier/frp/blob/main/pkg/config/v1/proxy.go)**. This configuration supports both TCP and HTTP probe types with customizable intervals, timeouts, and failure thresholds.

```go
// 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`](https://github.com/fatedier/frp/blob/main/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:

```go
// 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`](https://github.com/fatedier/frp/blob/main/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:

```go
// 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:

```go
// 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`](https://github.com/fatedier/frp/blob/main/client/proxy/proxy_wrapper.go)**, which update an atomic health flag and notify a control channel:

```go
// 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`](https://github.com/fatedier/frp/blob/main/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:

```toml
[[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:

```toml
[[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`](https://github.com/fatedier/frp/blob/main/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`](https://github.com/fatedier/frp/blob/main/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`](https://github.com/fatedier/frp/blob/main/frpc.toml) file.