Kubernetes LoadBalancer Annotations
This page is everything you need to expose a Kubernetes Service to the internet (or to other VPC workloads) on any Hypervisor.io-powered cloud. Apply a Service: type=LoadBalancer, add a couple of annotations, and your operator's platform stands up a fully managed load balancer with a public IP within a minute or two.
Overview #
Clusters provisioned through a Hypervisor.io control panel ship with a built-in Kubernetes integration that watches your Services and provisions matching load balancers on the underlying platform. Once a Service of type: LoadBalancer is applied:
- A managed load balancer is provisioned in your region.
- Its public (or VPC-internal) IP is written to
Service.status.loadBalancer.ingress[0].ip. - Worker nodes are added to the LB's backend pool. Auto-scaling adds/removes them automatically.
- Updates to the Service (port changes, annotation tweaks) are reconciled live - no LB recreation.
service.beta.kubernetes.io/managed-loadbalancer-. The rest of the key (for example plan, ssl-mode) is documented below.
Your first Service #
The minimum viable manifest: type: LoadBalancer plus a plan annotation that picks a Load Balancer plan available in your cluster's region.
apiVersion: v1 kind: Service metadata: name: web annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g spec: type: LoadBalancer selector: app: nginx ports: - port: 80 targetPort: 8080
Apply it with kubectl apply -f svc.yaml and watch the IP populate:
$ kubectl get svc web -w NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE web LoadBalancer 10.96.142.10 <pending> 80:30421/TCP 5s web LoadBalancer 10.96.142.10 203.0.113.42 80:30421/TCP 1m
std-1g, std-2g, ha-2g. Check the Hypervisor.io dashboard → Load Balancer Plans, or ask your operator.
How it works #
The load balancer doesn't forward directly to pod IPs - it forwards to worker NodePorts allocated by Kubernetes. From there, in-cluster routing delivers the packet to a pod.
External client
│
▼
LB:443 (provisioned by the Hypervisor.io control panel)
│
│ Round-robin across worker NodePorts
▼
[ worker1:30421, worker2:30421, worker3:30421 ]
│
│ In-cluster routing to pod
▼
Pod 10.244.X.Y:8080
- Workers are tracked automatically. Autoscaler-added or removed workers update the LB's backend pool within seconds.
- Pin the NodePort with
spec.ports[].nodePort: 31234if downstream firewalls need a fixed port; otherwise Kubernetes picks one in the30000-32767range. - Pod IPs are never exposed externally. Only worker IPs (via NodePort) and the LB's frontend IP.
Annotations #
Every annotation begins with the prefix service.beta.kubernetes.io/managed-loadbalancer-. The rest of the key (e.g. plan, ssl-mode) is what's shown in the tables below.
Required - plan #
| Annotation | Type | Description |
|---|---|---|
plan required |
string | Name of an LB plan enabled in your cluster's region. Service creation fails with a Kubernetes Event if missing or unknown. |
<pending> and a Kubernetes Event explains why. Use kubectl describe svc <name> to inspect.
All optional annotations #
Quick reference. Each is described in detail in the sections that follow.
| Annotation | Type | Default | Description |
|---|---|---|---|
public | bool | true | Whether the LB gets a public IP. |
vpc-only | bool | false | Synonym for public: "false". |
public-ip | string | auto | Reserve a specific public IP that you already own. |
source-ranges | csv CIDR | 0.0.0.0/0 | Allowed source CIDRs. |
proxy-protocol | v1/v2/bool | false | Send PROXY protocol header to backends. v2 binary, v1 text. |
ssl-redirect | bool | false | Auto HTTP → HTTPS 301 redirect on L7 frontends. |
port-{N}-<key> | varies | - | Per-port override for backend-protocol, ssl-mode, ssl-domain, ssl-cert, ssl-key. |
routing-rules | JSON | - | SNI / path / host matching per port. See Routing rules. |
traffic-split | JSON | - | Blue/green and canary traffic splitting across child Services. See Traffic splitting. |
ha | bool | false | Enable HA (active-passive). Requires an HA-capable LB plan. |
firewall | bool | true | Enable LB firewall (otherwise wide-open on frontend ports). |
backend-protocol | enum | tcp | tcp, http, or https. Picks L4 vs L7 mode. |
security-groups | csv | - | Security groups to attach to the LB. |
ssl-mode | enum | none | none, letsencrypt, or inline. |
ssl-domain | string | - | FQDN for the certificate. |
ssl-cert | base64 | - | Inline mode only. Base64-encoded PEM fullchain. |
ssl-key | base64 | - | Inline mode only. Base64-encoded PEM private key. |
health-check-* | varies | sensible defaults | Active health probes (see Health checks). |
passive-check-* | varies | off | Mark backends down on live-traffic errors. |
SSL / TLS termination #
The LB can terminate TLS for you using one of three modes via ssl-mode.
ssl-mode | Behaviour |
|---|---|
none (default) | Pass through encrypted bytes. The LB does not see plaintext. |
letsencrypt | The platform auto-issues and auto-renews a free Let's Encrypt certificate. |
inline | You provide a PEM certificate and key inline. |
Let's Encrypt
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: api.example.com spec: type: LoadBalancer ports: - port: 443 targetPort: 8080
Requirements:
- The LB must have a public IP (
public: "true", the default). - The domain's
A/AAAArecord must resolve to the LB's public IP before Let's Encrypt verification.
DNS is polled every 5 minutes for up to 24 hours; the certificate is issued as soon as it resolves. Renewal runs automatically 30 days before expiration.
ssl-mode: none, read the EXTERNAL-IP, point your DNS at it, then kubectl edit svc to switch ssl-mode to letsencrypt.
Inline certificate + key
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: inline service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: api.example.com service.beta.kubernetes.io/managed-loadbalancer-ssl-cert: | LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMrek... service.beta.kubernetes.io/managed-loadbalancer-ssl-key: | LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QU...
Encode the PEM with base64 -w0 fullchain.pem and base64 -w0 privkey.pem. ssl-cert must be the full chain (leaf + intermediates).
Backend protocol (L4 vs L7) #
Kubernetes spec.ports[].protocol only accepts TCP/UDP/SCTP. To get L7 features (SSL termination, HTTP health checks) tell the LB to run in HTTP mode via annotation.
| Annotation | Values | Default | Effect |
|---|---|---|---|
backend-protocol | tcp, http, https | tcp | LB frontend + backend mode. http/https enable L7 features. |
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: api.example.com spec: type: LoadBalancer ports: - port: 443 targetPort: 80 # pod plain HTTP; LB terminates TLS
tcp- raw L4 passthrough. Pods see the encrypted bytes if there are any.http- L7. Pair withssl-modeto terminate TLS at the LB.https- L7 with re-encryption to the backend. Rare; useful when pods require mTLS.
Health checks #
The LB probes each worker NodePort on a schedule. Defaults are sensible - override only what you need.
| Annotation | Type | Default | Description |
|---|---|---|---|
health-check-enabled | bool | true | Disable to stop probing entirely. |
health-check-protocol | tcp / http | mirrors backend-protocol | Probe layer. http enables path + expect. |
health-check-port | int / traffic-port | NodePort | Where to probe. traffic-port = same as live traffic. |
health-check-path | string | / (http only) | HTTP path. Ignored for tcp. |
health-check-interval | seconds | 5 | Probe frequency. |
health-check-timeout | seconds | 3 | Per-probe timeout. |
health-check-healthy-threshold | int | 2 | Consecutive OK probes before marked UP. |
health-check-unhealthy-threshold | int | 3 | Consecutive failures before marked DOWN. |
health-check-expect | string | unset | http only. e.g. status 200 or string OK. |
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-health-check-protocol: http service.beta.kubernetes.io/managed-loadbalancer-health-check-path: /healthz service.beta.kubernetes.io/managed-loadbalancer-health-check-interval: "5" service.beta.kubernetes.io/managed-loadbalancer-health-check-expect: status 200
Passive checks #
The LB can also mark a backend down based on real traffic - cheaper than active probing alone and catches failures that only manifest under load.
| Annotation | Type | Default | Description |
|---|---|---|---|
passive-check-enabled | bool | false | Observe live traffic. |
passive-check-error-limit | int | 10 | Consecutive errors before action fires. |
passive-check-on-error | mark-down / fail-check / sudden-death / fastinter | mark-down | What to do when the limit is hit. |
Layer is picked automatically - L7 when backend mode is http, else L4.
Source range filtering #
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-source-ranges: "10.0.0.0/8,203.0.113.0/24"
Defaults to 0.0.0.0/0 (open to the internet). Multiple CIDRs are comma-separated. Both IPv4 and IPv6 CIDRs are accepted.
spec.loadBalancerSourceRanges - both work. If you set both, the annotation wins.
Security groups #
Attach Security Groups defined in your Hypervisor.io control panel (by name or UUID) to the LB. Useful for sharing rule sets across many Services.
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-security-groups: web-allow-443,monitoring-allow-9100
- Multiple entries are comma-separated.
- Names and UUIDs can be mixed in the same list.
- The SG must be owned by your account.
- Unknown entries are skipped with a warning Event on the Service.
- Editing the annotation re-applies SGs - no LB recreation needed.
Public vs VPC-only #
By default the LB gets a public IP. To deploy the LB inside the VPC only:
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-public: "false" # Or equivalently: # service.beta.kubernetes.io/managed-loadbalancer-vpc-only: "true"
The Service's EXTERNAL-IP will be a VPC-internal address. Use this for internal microservices that should not be exposed to the internet.
Reserving a specific public IP
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-public-ip: 203.0.113.42
The IP must already be a reserved static IP belonging to your account. Useful when DNS already points to a specific IP.
High availability #
Enable active-passive LB HA (requires an HA-capable LB plan):
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: ha-2g service.beta.kubernetes.io/managed-loadbalancer-ha: "true"
If the plan does not support HA, the Service errors with plan_not_ha_capable.
PROXY protocol #
Forward client connection metadata (real source IP, original port) to backends using PROXY protocol. Three values accepted:
| Value | Meaning |
|---|---|
"v2" | Binary PROXY protocol v2 header. Lower overhead. Preferred. |
"v1" | Text PROXY protocol v1 header. Older format, easier to debug. |
"true" | Alias for v2. |
"false" (default) | Off. |
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-proxy-protocol: "v2"
proxy_protocol support on the listening port before enabling this annotation.
SSL redirect #
Auto 301 redirect from HTTP to HTTPS. Mirrors AWS aws-load-balancer-ssl-redirect. Requires backend-protocol: http and a port 80 frontend.
| Annotation | Type | Default | Description |
|---|---|---|---|
ssl-redirect | bool | false | Add HTTP → HTTPS 301 on HTTP-mode frontends. Skips ACME challenge path so Let's Encrypt renewals still work. |
apiVersion: v1 kind: Service metadata: name: web annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: "http" service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: web.example.com service.beta.kubernetes.io/managed-loadbalancer-ssl-redirect: "true" spec: type: LoadBalancer ports: - name: http port: 80 targetPort: 80 - name: https port: 443 targetPort: 80
The LB intercepts every HTTP request on port 80 and returns 301 Moved Permanently to https://<same-host><same-path>, except /.well-known/acme-challenge/* which is left intact so Let's Encrypt renewals on port 80 keep working.
Per-port settings #
Any global annotation can be overridden for a single frontend port using the pattern port-{N}-<key>, where {N} is the spec.ports[].port value. Supported keys: backend-protocol, ssl-mode, ssl-domain, ssl-cert, ssl-key.
If a per-port key is unset, the corresponding global annotation is used. If neither is set, the documented default applies.
This lets a single Service expose plain TCP, Let's Encrypt-terminated HTTPS, inline-cert HTTPS, and another raw TCP port - all from one LB.
apiVersion: v1 kind: Service metadata: name: multi-proto annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g # Port 80 - plain TCP passthrough (default) service.beta.kubernetes.io/managed-loadbalancer-port-80-backend-protocol: tcp # Port 443 - HTTP mode + Let's Encrypt for web.example.com service.beta.kubernetes.io/managed-loadbalancer-port-443-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-port-443-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-port-443-ssl-domain: web.example.com # Port 8443 - HTTP mode + inline cert for api.example.com service.beta.kubernetes.io/managed-loadbalancer-port-8443-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-port-8443-ssl-mode: inline service.beta.kubernetes.io/managed-loadbalancer-port-8443-ssl-domain: api.example.com service.beta.kubernetes.io/managed-loadbalancer-port-8443-ssl-cert: | <base64 PEM fullchain> service.beta.kubernetes.io/managed-loadbalancer-port-8443-ssl-key: | <base64 PEM private key> # Port 5432 - raw TCP passthrough to Postgres pods service.beta.kubernetes.io/managed-loadbalancer-port-5432-backend-protocol: tcp spec: type: LoadBalancer selector: app: web ports: - name: http port: 80 targetPort: 80 - name: https port: 443 targetPort: 80 - name: api port: 8443 targetPort: 8080 - name: postgres port: 5432 targetPort: 5432
Routing rules advanced #
For workloads that need multiple TLS certs on the same port, or that route by URL path or HTTP Host header, set the routing-rules annotation to a JSON array. Each rule binds to one port on the LB and specifies how to match incoming traffic.
Schema
[
{"port": 443, "match": "sni|path|host", "value": "...", "ssl_domain": "..."}
]
| Field | Required | Description |
|---|---|---|
port | yes | LB frontend port the rule applies to. Must exist in spec.ports. |
match | yes | One of sni, path, host. |
value | yes | Matched value: domain for sni/host, URL prefix for path. |
ssl_domain | sni only | Per-rule Let's Encrypt domain. Issued separately and added to the frontend's crt-list. |
To split traffic across multiple versions of your app (blue/green, canary), see Traffic splitting. A routing rule can also be combined with a weighted split.
SNI multi-domain on one port
Most common case: one port 443, multiple TLS certs, the LB picks the right cert from the ClientHello SNI extension.
apiVersion: v1 kind: Service metadata: name: web-multi-sni annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: web.example.com service.beta.kubernetes.io/managed-loadbalancer-routing-rules: | [ {"port": 443, "match": "sni", "value": "web.example.com", "ssl_domain": "web.example.com"}, {"port": 443, "match": "sni", "value": "shop.example.com", "ssl_domain": "shop.example.com"}, {"port": 443, "match": "sni", "value": "api.example.com", "ssl_domain": "api.example.com"} ] spec: type: LoadBalancer ports: - name: https port: 443 targetPort: 80
The global ssl-domain provides the default cert (served when the client sends no SNI, or sends an unknown hostname). Each rule adds its own cert.
Path-based routing
Route by URL path prefix. Useful for splitting /api vs / to different backend pools. TLS termination is governed by the global ssl-* annotations.
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: web.example.com service.beta.kubernetes.io/managed-loadbalancer-routing-rules: | [ {"port": 443, "match": "path", "value": "/api"}, {"port": 443, "match": "path", "value": "/static"} ]
Host-based routing
Route by HTTP Host: header. Requires backend-protocol: http. Unlike SNI this works after TLS termination, so it can split traffic that arrived under the same cert.
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: web.example.com service.beta.kubernetes.io/managed-loadbalancer-routing-rules: | [ {"port": 443, "match": "host", "value": "admin.example.com"}, {"port": 443, "match": "host", "value": "www.example.com"} ]
ssl_domain if no longer referenced). Setting the annotation to [] wipes all rules. Unsetting the key entirely leaves existing rules alone.
Traffic splitting advanced #
Split traffic across multiple versions of your app by weight. Common patterns: blue/green cutovers, canary rollouts, gradual migrations. No service mesh required.
Two forms - standalone vs embedded
Traffic splitting comes in two shapes. The difference is whether the split is global or scoped to a routing match.
| Form | Annotation | Scope | Match criteria |
|---|---|---|---|
| Standalone | traffic-split | Every frontend port on the Service | None (catch-all) |
| Embedded | routing-rules[].backends | One specific routing rule | The rule's match+value (SNI, path, host) |
The mental model is split into two questions:
- Routing rules decide which backend receives a request (by SNI / path / host).
- Traffic split decides how requests are weighted across multiple backends.
You can have one, the other, or both. The three valid combinations:
| What you want | What to use |
|---|---|
| One backend per request, picked by SNI / path / host | routing-rules alone |
| Weighted distribution across child Services, same everywhere | traffic-split alone |
| Different hostnames AND each hostname needs a weighted split | routing-rules with backends arrays inside each rule |
Which form to use - decision tree
Do you need to split traffic across multiple Services?
|
+-- No -> single backend; no traffic-split / routing-rules needed
|
+-- Yes -> Are the splits different per SNI / path / host?
|
+-- No (same split everywhere) -> use STANDALONE
| service.beta.kubernetes.io/managed-loadbalancer-traffic-split
|
+-- Yes (per-route splits) -> use EMBEDDED
routing-rules with backends: [] per rule
routing-rules entry with a backends array IS traffic splitting - just the embedded form. Use standalone traffic-split only when you don't need any SNI / path / host filter; otherwise put the backends array directly inside the rule that matches.
node_port (the NodePort allocated by Kubernetes for that Service). Get it from kubectl get svc -n <namespace> <name> -o jsonpath='{.spec.ports[0].nodePort}'. Auto-resolution from the Service name is on the roadmap (in-cluster CCM reconciler); explicit node_port is the current contract.
Blue/green - 80/20
Declare two child Services of type: NodePort (one per version), then a parent type: LoadBalancer Service that references them with weights.
apiVersion: v1 kind: Service metadata: name: app-blue namespace: prod spec: type: NodePort selector: { app: web, version: blue } ports: [{ port: 80, targetPort: 8080 }] --- apiVersion: v1 kind: Service metadata: name: app-green namespace: prod spec: type: NodePort selector: { app: web, version: green } ports: [{ port: 80, targetPort: 8080 }] --- apiVersion: v1 kind: Service metadata: name: app namespace: prod annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-traffic-split: | [ { "service": "app-blue", "weight": 80, "node_port": 31080 }, { "service": "app-green", "weight": 20, "node_port": 31090 } ] spec: type: LoadBalancer ports: [{ port: 443, targetPort: 80 }]
To finish a blue/green cutover, change the weights to { blue: 0, green: 100 } and re-apply. Existing connections drain naturally on the zero-weight backend; new ones all go to green.
Canary on a subdomain
Combine a host match with a weighted split. Requests to canary.example.com reach the canary only; app.example.com gets a 95/5 split.
metadata: annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-routing-rules: | [ { "port": 443, "match": "host", "value": "canary.example.com", "backends": [{ "service": "app-canary", "weight": 100, "node_port": 31090 }] }, { "port": 443, "match": "host", "value": "app.example.com", "backends": [ { "service": "app-stable", "weight": 95, "node_port": 31080 }, { "service": "app-canary", "weight": 5, "node_port": 31090 } ] } ]
Rules without a backends array continue to behave as plain match rules pointing at the parent Service's pool.
Protocol modes - TCP, HTTP, HTTPS
The mode of the rendered HAProxy frontend is decided by the backend-protocol annotation, not by the split itself. Traffic-split works identically in all three modes - the split routes by weight, the mode controls how the LB handles the bytes.
| Annotation | HAProxy mode | When to use |
|---|---|---|
| (unset, default) | tcp | L4 passthrough, raw TCP, TLS passthrough (pods own the cert) |
backend-protocol: http | http | LB terminates SSL (LE or inline), plain HTTP to pods; unlocks L7 health checks and path/host routing |
Full copy-pasteable recipes for each mode are in the Recipes section below:
- Blue/green - raw TCP (or TLS passthrough)
- Blue/green - HTTP, no TLS
- Blue/green - HTTPS with Let's Encrypt + ssl-redirect
- Per-domain split - HTTPS multi-cert canary
Weight semantics & limits
- Weights are integers from
0to1000. They do not need to sum to 100. - A backend with weight
0keeps health checks running but stops new connections - use it to drain a version without removing it. - Child Services must be
type: NodePortand in the same namespace as the parent. - Match types:
host,path,sni. Header-based matches are not yet supported in a split. - Each child Service consumes a NodePort. Child Services do not get their own load balancers - only the parent does.
- Cross-namespace child references are blocked by default; operators can enable them per-cluster.
curl and confirm the ratio matches your declared weights within a few percent.
Recipe - HTTPS web app with Let's Encrypt #
Most common pattern. Public LB, TLS terminated at the LB, plain HTTP to the pods, HTTP-aware health checks.
apiVersion: v1 kind: Service metadata: name: webapp annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-2g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: app.example.com service.beta.kubernetes.io/managed-loadbalancer-health-check-protocol: http service.beta.kubernetes.io/managed-loadbalancer-health-check-path: /healthz spec: type: LoadBalancer selector: app: webapp ports: - port: 443 targetPort: 8080
Recipe - Internal-only Service #
VPC-internal LB - reachable only from inside your private network. No public IP, no internet exposure.
apiVersion: v1 kind: Service metadata: name: internal-api annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-vpc-only: "true" spec: type: LoadBalancer selector: app: internal-api ports: - port: 8080 targetPort: 8080
Recipe - Raw TCP (Postgres, Redis, etc.) #
L4 passthrough - the LB does not look at the payload. Ideal for databases and any non-HTTP protocol.
apiVersion: v1 kind: Service metadata: name: postgres-lb annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: tcp service.beta.kubernetes.io/managed-loadbalancer-source-ranges: "10.0.0.0/8" service.beta.kubernetes.io/managed-loadbalancer-health-check-protocol: tcp spec: type: LoadBalancer selector: app: postgres ports: - port: 5432 targetPort: 5432 protocol: TCP
Recipe - Multi-tenant SNI fronting advanced #
One public IP, one LB, multiple hostnames - each tenant served by a different in-cluster Service, with a weighted blue/green for one of them. SaaS-style fan-out. Pattern combines routing rules + traffic splitting.
Layout: parent type: LoadBalancer with no selector (all routing via rules), N child NodePort Services in the same namespace, one routing rule per tenant. backends array on a rule turns it into a weighted split. Apply once, change weights later for canary or blue/green.
# Tenant A - single backend apiVersion: v1 kind: Service metadata: name: grafana namespace: tenants spec: type: NodePort selector: { app: grafana } ports: [{ port: 80, targetPort: 3000 }] --- # Tenant B - blue/green pair (100/0 to start) apiVersion: v1 kind: Service metadata: name: app-blue namespace: tenants spec: type: NodePort selector: { app: myapp, version: blue } ports: [{ port: 80, targetPort: 5678 }] --- apiVersion: v1 kind: Service metadata: name: app-green namespace: tenants spec: type: NodePort selector: { app: myapp, version: green } ports: [{ port: 80, targetPort: 5678 }] --- # Single multi-tenant LB Service. No selector - parent has no pod backends # of its own. All routing is done by the rules below. apiVersion: v1 kind: Service metadata: name: tenant-gateway namespace: tenants annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-redirect: "true" service.beta.kubernetes.io/managed-loadbalancer-routing-rules: | [ { "port": 443, "match": "sni", "value": "grafana.example.com", "ssl_domain": "grafana.example.com", "backends": [{ "service": "grafana", "weight": 100, "node_port": 30080 }] }, { "port": 443, "match": "sni", "value": "app.example.com", "ssl_domain": "app.example.com", "backends": [ { "service": "app-blue", "weight": 100, "node_port": 31080 }, { "service": "app-green", "weight": 0, "node_port": 31090 } ] } ] spec: type: LoadBalancer ports: - name: https port: 443 protocol: TCP - name: http port: 80 protocol: TCP
What this does
- One public IP, one LB instance - all tenants share the same frontend.
- SNI-based routing at port 443 - the LB inspects the TLS ClientHello to pick the right cert + backend pool.
- Per-tenant Let's Encrypt - each rule mints + auto-renews its own cert (
ssl_domain). - HTTP → HTTPS redirect at port 80 (via
ssl-redirect) so plaintext hits are upgraded. - Weighted blue/green on
app.example.com- flip to{ blue: 0, green: 100 }and re-apply to cut over. - Selectorless parent - the LB Service itself has no pods; unmatched SNI hostnames are rejected (intended for tenant isolation).
Operational notes
- All tenant hostnames must resolve to the LB's public IP before Let's Encrypt can issue. Pin a specific IP with
public-ip:so you can set DNS upfront. - Child Services must be
type: NodePortand live in the same namespace as the parent. - Add a tenant: append a new rule + apply. Remove a tenant: drop the rule + apply. No restart, no LB churn.
- For canary rollouts on any tenant, replace the single-backend array with a two-entry weighted array and bump weights over time.
- If you want a default catch-all (unmatched SNI), add a rule with
"value": "*"as the last entry - HAProxy treats it as a wildcard match.
Recipe - Blue/green - TCP advanced #
Default mode if you set no backend-protocol. HAProxy proxies raw bytes; works for plain TCP services and TLS-passthrough (where pods serve the cert). Uses 80/20 weights to start - flip to [0, 100] to complete the cutover.
# Blue Service apiVersion: v1 kind: Service metadata: { name: app-blue, namespace: prod } spec: type: NodePort selector: { app: testapp, version: blue } ports: [{ port: 80, targetPort: 5678, nodePort: 31080 }] --- # Green Service apiVersion: v1 kind: Service metadata: { name: app-green, namespace: prod } spec: type: NodePort selector: { app: testapp, version: green } ports: [{ port: 80, targetPort: 5678, nodePort: 31090 }] --- # Managed LoadBalancer (parent) - TCP mode by default apiVersion: v1 kind: Service metadata: name: app namespace: prod annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: k8s-lb service.beta.kubernetes.io/managed-loadbalancer-public: "true" service.beta.kubernetes.io/managed-loadbalancer-traffic-split: | [ { "service": "app-blue", "weight": 80, "node_port": 31080 }, { "service": "app-green", "weight": 20, "node_port": 31090 } ] spec: type: LoadBalancer ports: [{ port: 443, targetPort: 80, protocol: TCP }]
HAProxy renders mode tcp with a single backend containing 6 server lines (3 worker nodes × 2 child NodePorts) and HAProxy-side weight per child. Health checks are TCP-only (connection-open / close); use the HTTP recipe below if you need GET /healthz probes.
For HTTPS passthrough (pods own the cert), swap the parent targetPort to your pods' TLS port and point both child NodePorts at it. No annotation changes - HAProxy still TCP-passes the encrypted bytes.
Recipe - Blue/green - HTTP advanced #
L7 mode without TLS. The LB speaks HTTP to pods and can do HTTP-aware health checks (GET /) plus method/path/status request logs. Use when traffic is internal-only or TLS is terminated upstream of the LB.
# app-blue + app-green Services as in the TCP recipe above --- apiVersion: v1 kind: Service metadata: name: app namespace: prod annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: k8s-lb service.beta.kubernetes.io/managed-loadbalancer-public: "true" service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-health-check-protocol: http service.beta.kubernetes.io/managed-loadbalancer-health-check-path: / service.beta.kubernetes.io/managed-loadbalancer-traffic-split: | [ { "service": "app-blue", "weight": 80, "node_port": 31080 }, { "service": "app-green", "weight": 20, "node_port": 31090 } ] spec: type: LoadBalancer ports: [{ port: 80, targetPort: 80, protocol: TCP }]
Adding backend-protocol: http flips the rendered HAProxy frontend to mode http. The split still operates identically - each connection draws a child by weight, then HAProxy forwards the HTTP request to that child's NodePort.
Recipe - Blue/green - HTTPS with Let's Encrypt advanced #
Most common production shape. LB terminates TLS using an automatically-issued Let's Encrypt cert, talks plain HTTP to pods, and 301-redirects port 80 to 443.
Point your DNS A record at the Service's EXTERNAL-IP before applying so LE's HTTP-01 challenge can complete on first reconcile.
# app-blue + app-green Services as in the TCP recipe above --- apiVersion: v1 kind: Service metadata: name: app namespace: prod annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: k8s-lb service.beta.kubernetes.io/managed-loadbalancer-public: "true" service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: app.example.com service.beta.kubernetes.io/managed-loadbalancer-ssl-redirect: "true" service.beta.kubernetes.io/managed-loadbalancer-health-check-protocol: http service.beta.kubernetes.io/managed-loadbalancer-health-check-path: / service.beta.kubernetes.io/managed-loadbalancer-traffic-split: | [ { "service": "app-blue", "weight": 80, "node_port": 31080 }, { "service": "app-green", "weight": 20, "node_port": 31090 } ] spec: type: LoadBalancer ports: - { name: https, port: 443, targetPort: 80, protocol: TCP } - { name: http, port: 80, targetPort: 80, protocol: TCP }
Standalone traffic-split applies to every port in spec.ports, so both 443 (HTTPS) and 80 (the redirect frontend) inherit the 80/20 split.
The ssl-redirect annotation injects a 301 on port 80 that skips the /.well-known/acme-challenge/ path so LE renewals continue to work after the redirect is enabled.
Recipe - Per-domain split (HTTPS canary) advanced #
Combine routing-rules with embedded backends when different hostnames need different splits. Each rule can carry its own ssl_domain so the LB serves the right LE cert via SNI.
# admin-v1, admin-v2, web NodePort Services in tenants namespace (omitted) --- apiVersion: v1 kind: Service metadata: name: ingress namespace: tenants annotations: service.beta.kubernetes.io/managed-loadbalancer-plan: k8s-lb service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: web.example.com service.beta.kubernetes.io/managed-loadbalancer-routing-rules: | [ { "port": 443, "match": "host", "value": "admin.example.com", "ssl_domain": "admin.example.com", "backends": [ { "service": "admin-v1", "weight": 95, "node_port": 31080 }, { "service": "admin-v2", "weight": 5, "node_port": 31090 } ] }, { "port": 443, "match": "host", "value": "web.example.com", "backends": [ { "service": "web", "weight": 100, "node_port": 31200 } ] } ] spec: type: LoadBalancer ports: [{ port: 443, targetPort: 80, protocol: TCP }]
admin.example.com gets a 95/5 canary across admin-v1 and admin-v2. web.example.com goes 100% to web. The global ssl-domain issues the fallback cert that HAProxy serves when the client sends no SNI or sends an unknown hostname.
Full production example #
Everything in one manifest: plan, L7 mode with Let's Encrypt, active and passive health checks, locked-down sources, attached security groups, real client IPs via PROXY protocol.
apiVersion: v1 kind: Service metadata: name: webapp annotations: # Plan + L7 mode + Let's Encrypt service.beta.kubernetes.io/managed-loadbalancer-plan: std-2g service.beta.kubernetes.io/managed-loadbalancer-backend-protocol: http service.beta.kubernetes.io/managed-loadbalancer-ssl-mode: letsencrypt service.beta.kubernetes.io/managed-loadbalancer-ssl-domain: app.example.com # Active + passive health checks service.beta.kubernetes.io/managed-loadbalancer-health-check-protocol: http service.beta.kubernetes.io/managed-loadbalancer-health-check-path: /healthz service.beta.kubernetes.io/managed-loadbalancer-health-check-interval: "5" service.beta.kubernetes.io/managed-loadbalancer-health-check-expect: status 200 service.beta.kubernetes.io/managed-loadbalancer-passive-check-enabled: "true" service.beta.kubernetes.io/managed-loadbalancer-passive-check-error-limit: "5" # Lock down sources + attach SGs service.beta.kubernetes.io/managed-loadbalancer-source-ranges: "0.0.0.0/0" service.beta.kubernetes.io/managed-loadbalancer-security-groups: web-ddos-rules # Real client IPs through PROXY protocol service.beta.kubernetes.io/managed-loadbalancer-proxy-protocol: "true" spec: type: LoadBalancer selector: app: webapp ports: - name: http port: 80 targetPort: 8080 - name: https port: 443 targetPort: 8080
Updating annotations #
Edit the Service in-place - the LB reconciles within a few seconds.
kubectl edit svc webapp # or: kubectl annotate svc webapp \ service.beta.kubernetes.io/managed-loadbalancer-source-ranges="10.0.0.0/8" \ --overwrite
- Adding/removing ports: no dropped connections.
- Editing SSL annotations: certificate re-applied (already-active Let's Encrypt certs are not re-issued).
- Editing security groups: SGs re-applied to the LB.
- Editing the plan: the LB is destroyed and recreated - plan changes are not in-place. The public IP is preserved.
Errors & troubleshooting #
Failures show up as Kubernetes Events on the Service. Inspect with kubectl describe svc <name>:
$ kubectl describe svc webapp
...
Events:
Type Reason Message
---- ------ -------
Warning CreateLoadBalancerFailed unknown_lb_plan: std-9xl
| Code | Meaning |
|---|---|
missing_annotation | A required annotation is absent (usually plan). |
unknown_lb_plan | The plan name doesn't exist or has been disabled. |
lb_plan_not_in_region | The plan exists but isn't enabled in your cluster's region. |
plan_not_ha_capable | ha: "true" was set but the plan doesn't support HA. |
invalid_cidr | A source-ranges entry is not a valid CIDR. |
invalid_ssl_pem | Inline SSL cert/key failed parsing (likely not valid base64-encoded PEM). |
ssl_dns_not_resolved | Let's Encrypt issuance is paused because DNS doesn't yet resolve to the LB. |
ssl_dns_timeout | Let's Encrypt issuance failed - DNS didn't resolve within 24h. |
static_ip_not_owned | public-ip references an IP not owned by your account. |
static_ip_in_use | public-ip is already assigned elsewhere. |
Common situations
- EXTERNAL-IP stuck on
<pending>- check the Service's Events. Usually a missing or wrongplan. - Let's Encrypt stuck on
ssl_dns_not_resolved- verify the domain's A record points to the LB'sEXTERNAL-IP. DNS is rechecked every 5 minutes for up to 24 hours. - Backends marked unhealthy - verify the health-check path and expected status. Run
kubectl get endpoints <svc>to confirm pods are matched by the selector. - Need help? Reach out via support or the Discord.
Ready to ship a Service?
Pick a recipe, paste it into a manifest, run kubectl apply.