Expose your service in Kubernetes to the Internet with open source FRP!
Custom Resources
Client — declarative FRP client instance connecting to an external FRP serverUpstream — a Kubernetes Service/port exposed through FRPVisitor — inbound tunnel that consumes another client’s STCP/XTCP Upstream (for P2P scenarios)ServerPool — a pool of frps servers that backs Service type=LoadBalancer (see LoadBalancer Controller)Server (alpha) — frps itself, run by the operator in a public cluster for private clusters to connect to (see Server (frps))Upstream protocols
TCP and UDP — straightforward port forwarding with optional health checks and bandwidth limitsSTCP — secret-key TCP, private to clients that share the key (with allowUsers)XTCP — encrypted peer-to-peer with NAT traversal, including the enableAssistedAddrs toggle for STUN-only or full address discovery, and an STCP fallback visitorHTTP / HTTPS — virtual-host routing, custom headers, locations, basic auth, and per-route health checksTCPMUX — multiplexed TCP for sharing a single server port across upstreamsSecure access to the FRP Server
Secret)Secret) and allowUsers, with user on Client and serverUser on Visitor for cross-user accessAdvanced traffic features
Upstream (e.g. static_file, unix_domain_socket, http_proxy, socks5, https2http, etc.)Client (protocol: tcp/kcp/quic/websocket/wss, pool count, multiplexing, dial timeout/keepalive, egress proxy via proxyURL with Secret-sourced proxyCredentials, also used for the OIDC token request) and per Upstream (encryption, compression, bandwidth limits)protocol: wss needs a TLS terminator in front of frps (frps itself only accepts websocket); the e2e suite runs nginx for thisOperational features
Client to set resources, node selectors, tolerations, labels, annotations, affinity, security context, environment variables (e.g. GOMEMLIMIT), and moreenabled: false on Upstream and Visitor to pause a tunnel without deleting it (its port is released)transport.wireProtocol) on Client for an AEAD-encrypted control channel (frps >= v0.69.0)Client reports a clientID of <namespace>/<name> in the frps dashboard (frps >= v0.67.0; set spec.clientID: "" to omit) — frps allows only one online client per clientIDexecs into the pod and verifies /frp/config.toml matches the expected state, and runs frpc verify on the rendered config before triggering the reloadUpstream server ports (per protocol, so 53/TCP and 53/UDP can coexist), duplicate Visitor ports, and missing Secrets/keys, surfaced via descriptive errorsService type=LoadBalancer through a pool of frps servers (ServerPool + loadBalancerClass: frp.zufardhiyaulhaq.com/frp)Client and proxy (frp_client_ready, frp_client_config_synced, frp_proxy_status, …) plus controller-runtime metrics, served on :8080 (or :8443 with Kubernetes token authentication when metrics.secure=true)ServiceMonitor and a Grafana dashboard (dashboards/frp-operator.json)To install the chart with the release name my-release:
helm repo add frp-operator https://zufardhiyaulhaq.com/frp-operator/charts/releases/
helm install my-frp-operator frp-operator/frp-operator --values values.yaml
To expose your private Kubernetes service into public network. You need public machine running FRP Server that act as a proxy. Currently the operator doesn’t have capability to spine a new machine on cloud providers, but this can be setup in a minute.
frps binary[common] bind_address = 0.0.0.0 bind_port = 7000 token = yourtoken
4. Run FRP server
frps -c ./frps.ini
You can reuse our build-in ansible playbook to setup the FRP server on your machine, please check https://github.com/zufardhiyaulhaq/frp-operator/tree/main/ansible/server
## Usage
1. Apply some example
```console
kubectl apply -f examples/deployment/
kubectl apply -f examples/client/
kubectl get upstream NAME AGE nginx 17m
3. access the URL
```console
http://178.128.100.87:8080/
Besides the hand-written Client/Upstream flow, the operator can act as a load-balancer controller: a Service of type: LoadBalancer with loadBalancerClass: frp.zufardhiyaulhaq.com/frp is bound to one frps server from a ServerPool, the operator generates the Client/Upstream resources in its own namespace, and the server’s public address lands in status.loadBalancer.ingress — exactly like a cloud load balancer, but through FRP. Charts and tools that already create LoadBalancer Services work unchanged.
A ServerPool lives in the operator namespace (next to the frps token Secrets) and lists the frps servers a Service may be bound to:
apiVersion: frp.zufardhiyaulhaq.com/v1alpha1
kind: ServerPool
metadata:
name: prod
namespace: frp-operator # the operator's namespace
spec:
allocationPolicy: Auto # Auto (default): eligible without annotation | Explicit: only when a Service names it
clientMode: PerService # PerService (default): one frpc pod per Service per server | Shared: one frpc pod per server
allowedPorts: ["80", "443", "8000-9000"] # optional; empty = any port
servers:
- name: sg-01 # stable key used in annotations and generated names (lowercase, max 40 chars)
host: 178.128.100.87 # address frpc dials
port: 7000
publicAddress: 178.128.100.87 # IP or hostname written to the Service status
allowedPorts: ["443"] # optional per-server override
authentication:
token:
secret:
name: sg-01-token
key: token
# transportProtocol, tls, transport, adminServer: same shapes as Client.spec.server
clientTemplate: # optional; applied to every generated Client
podTemplate:
resources:
limits:
memory: 64Mi
status.servers[].allocatedPorts shows which Service holds which port on each server, and the Ready condition reports validation problems (missing token Secret, duplicate or invalid server names, bad allowedPorts).
apiVersion: v1
kind: Service
metadata:
name: web
namespace: default
annotations:
frp.zufardhiyaulhaq.com/server-pool: prod # optional
frp.zufardhiyaulhaq.com/server: sg-01 # optional; "sg-01,sg-02" for one IP per server
spec:
type: LoadBalancer
loadBalancerClass: frp.zufardhiyaulhaq.com/frp
selector:
app: web
ports:
- name: https
port: 443 # opened as remotePort 443 on the frps server
targetPort: 8443
protocol: TCP
kubectl get svc web shows <pending> until the generated frpc client is Ready and its proxies are running, then the server’s publicAddress. kubectl describe svc web shows the Events explaining a pending state; the generated objects are lb-default-web-sg-01 (Client) and lb-default-web-sg-01-443 (Upstream) in the operator namespace.
| Annotation on the Service | Meaning |
|---|---|
frp.zufardhiyaulhaq.com/server-pool: <name> |
Use only this pool (required for pools with allocationPolicy: Explicit) |
frp.zufardhiyaulhaq.com/server: sg-01[,sg-02] |
Bind to exactly these servers; one ingress IP each, all-or-nothing |
frp.zufardhiyaulhaq.com/allocated-server |
Written by the operator; the current binding |
remotePort = Service port.allowedPorts) wins. Bindings are sticky: a bound Service never moves unless its server is removed from the pool.<pending> with an Event (PortUnavailable, PortNotAllowed, NoServerAvailable, …). First come, first served: a running Service never loses its IP to another one. Delete the holder and the pending Service binds within seconds.Ready and every proxy reports running.clientMode: Shared runs one frpc pod per server for all Services; PerService (default) runs one per Service per server.loadBalancer.enabled=false to turn the controller off.frp.zufardhiyaulhaq.com/server-pool or frp.zufardhiyaulhaq.com/server annotation on a bound Service is honoured and re-selects (a live tunnel may move); a pinned multi-server Service is all-or-nothing: if one pinned server is removed from the pool, all of its tunnels are torn down until the binding can be satisfied again.Every claimed Service carries the finalizer frp.zufardhiyaulhaq.com/loadbalancer, which the operator removes once it has cleaned up that Service’s generated Client/Upstream objects. Before running helm uninstall or setting loadBalancer.enabled=false, either delete the claimed Services or remove their loadBalancerClass so the operator can clean up first — otherwise those Services are left stuck deleting (or stuck with a stale finalizer) once the controller stops running.
If the operator is already gone and a Service is stuck on the finalizer, strip it manually:
kubectl patch svc <name> -p '{"metadata":{"finalizers":null}}' --type=merge
then delete the generated Client/Upstream objects yourself (label frp.zufardhiyaulhaq.com/managed-by=loadbalancer) in the operator namespace.
The operator matches Upstream objects to Client objects by name, across namespaces — an Upstream naming a generated Client (pool-<pool>-<server> in clientMode: Shared, lb-<ns>-<svc>-<server> in PerService) attaches to that Client’s live tunnel regardless of which namespace the Upstream lives in. This means any user who can create Upstream objects can attach to a generated Client if they can guess or discover its name. Restrict create on upstreams.frp.zufardhiyaulhaq.com to trusted namespaces via RBAC.
Alpha.
Serveris new in v0.13.0. Its API (frp.zufardhiyaulhaq.com/v1alpha1, kindServer) and behaviour may change in a later release without a migration path; try it before relying on it in production.
The operator can also run frps itself. A Server becomes one frps pod behind a public Service (LoadBalancer by default), and private clusters connect ordinary Clients or ServerPool servers to it. The Service exposes the bind ports, the vhost ports and every port in allowPorts, each with its own protocol.
apiVersion: frp.zufardhiyaulhaq.com/v1alpha1
kind: Server
metadata:
name: public-01
spec:
bind:
port: 7000 # tcp/websocket clients; kcpPort / quicPort for kcp / quic
tls:
certificate:
secret:
name: frps-cert # tls.crt, tls.key (+ ca.crt); unset = frps's random, unverifiable certificate
# clientCA: {secret: {name: frps-client-ca}} # mTLS
# force: true # default; false only for wss behind a TLS terminator
allowPorts: # remote ports for TCP/UDP Upstreams; nothing else is exposed
tcp: ["8000-8010"]
udp: ["5353"]
vhost:
httpPort: 80
httpsPort: 443
subdomainHost: example.com
# authentication: {token: {secret: {name: my-token, key: token}}} # unset = generated
# restartPolicy: Automatic # or Manual
service:
type: LoadBalancer # or ClusterIP
annotations:
metallb.universe.tf/loadBalancerIPs: 203.0.113.10
dashboard:
enabled: true # UI + admin API on 7500, through its own Service only
metrics:
enabled: true # /metrics behind the dashboard login
| Status | Meaning |
|---|---|
address |
The Client’s host: the load balancer’s IP or hostname, or <server>-frps.<namespace>.svc for ClusterIP |
tls.serverNames |
DNS names of bind.tls.certificate; use one as the Client’s tls.serverName |
exposedPorts |
The expanded allowPorts; a private ServerPool’s allowedPorts |
connectionSecret |
<server>-connection with token (and ca.crt); copy it to private clusters |
Ready |
True when the pod is ready and the address is set; otherwise PodStarting, PodFailed, AddressPending or LoadBalancerFailed |
ConfigSynced |
True when frps runs the current config; otherwise InvalidConfig, Restarting or RestartPending |
A static IP keeps private clusters’ host stable. Set it with your cloud’s Service annotation: service.beta.kubernetes.io/aws-load-balancer-eip-allocations (AWS), networking.gke.io/load-balancer-ip-addresses (GKE), service.beta.kubernetes.io/azure-load-balancer-ipv4 (Azure) or metallb.universe.tf/loadBalancerIPs (MetalLB). The Service is updated in place and never recreated, so its IP survives every change except deleting the Server.
Good to know:
restartPolicy: Manual waits until you delete the pod.Ready=False/LoadBalancerFailed.dashboard.tls raises a DashboardInsecure warning.server.maxPorts (default 50) ports.See examples/server.
The operator exposes Prometheus metrics on the <release>-controller-manager-metrics-service Service (port http/8080 by default):
| Metric | Labels | Description |
|---|---|---|
frp_client_info |
namespace, client, server_address, server_port, client_id, frpc_image |
Always 1 |
frp_client_ready |
namespace, client |
1 when the Ready condition is True |
frp_client_config_synced |
namespace, client |
1 when the ConfigSynced condition is True; 0 when the reload failed (ConfigReloadFailed) or the spec is invalid, e.g. a missing Secret (InvalidConfig, also emitted as a Warning event) |
frp_client_upstreams / frp_client_visitors |
namespace, client |
Attached resource counts |
frp_client_admin_up |
namespace, client |
1 when the frpc admin API answered /api/status |
frp_client_last_reconcile_timestamp_seconds |
namespace, client |
Unix time of the last reconcile |
frp_proxy_status |
namespace, client, proxy, type, status |
One-hot frpc proxy phase (running, start error, check failed, closed, wait start, new) |
frp_proxy_info |
namespace, client, proxy, type, local_addr, remote_addr, plugin |
Always 1 |
frp_loadbalancer_service |
namespace, service, pool, server, state |
Always 1; state is bound or pending |
frp_serverpool_allocated_ports |
pool, server |
Ports allocated on the server |
Enable scraping with metrics.serviceMonitor.enabled=true (Prometheus Operator; the VictoriaMetrics operator also consumes ServiceMonitor objects). With metrics.secure=true the scraper’s ServiceAccount must be allowed GET /metrics (bind it to the <release>-metrics-reader ClusterRole).
A Grafana dashboard lives at dashboards/frp-operator.json — import it into Grafana (Dashboards → New → Import) and pick your Prometheus/VictoriaMetrics datasource.
| Key | Type | Default | Description |
|---|---|---|---|
| loadBalancer.enabled | bool | true |
Bind Service type=LoadBalancer with loadBalancerClass: frp.zufardhiyaulhaq.com/frp to ServerPool servers |
| metrics.secure | bool | false |
|
| metrics.serviceMonitor.additionalLabels | object | {} |
|
| metrics.serviceMonitor.enabled | bool | false |
|
| metrics.serviceMonitor.interval | string | "30s" |
|
| operator.image | string | "ghcr.io/zufardhiyaulhaq/frp-operator" |
|
| operator.imagePullPolicy | string | "Always" |
Image pull policy for the operator. Always keeps moving tags such as main fresh; use IfNotPresent for a locally imported image (the e2e suite does). |
| operator.replica | int | 1 |
|
| operator.tag | string | "v0.13.0" |
|
| resources.limits.cpu | string | "200m" |
|
| resources.limits.memory | string | "100Mi" |
|
| resources.requests.cpu | string | "100m" |
|
| resources.requests.memory | string | "20Mi" |
|
| server.maxPorts | int | 50 |
Maximum number of ports on a Server’s public Service (bind, vhost and allowPorts). Cloud load balancers limit listeners. |
see example files here
Autogenerated from chart metadata using helm-docs v1.14.2