Kubernetes LoadBalancer

Managed Load Balancers,
from your Service manifests

Apply a Service: type=LoadBalancer on your cluster and the Hypervisor.io control panel provisions a real managed load balancer behind it. Pick a plan, terminate TLS with Let's Encrypt, restrict source IPs, attach security groups - all driven by annotations.

On this page

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:

  1. A managed load balancer is provisioned in your region.
  2. Its public (or VPC-internal) IP is written to Service.status.loadBalancer.ingress[0].ip.
  3. Worker nodes are added to the LB's backend pool. Auto-scaling adds/removes them automatically.
  4. Updates to the Service (port changes, annotation tweaks) are reconciled live - no LB recreation.
Annotation prefix. All annotations on this page use the prefix 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.

yaml
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:

bash
$ 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
Where do plan names come from? LB plans are configured by your platform administrator. Common names look like 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.

flow
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

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 #

AnnotationTypeDescription
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.
If the plan name is wrong or the plan isn't available in your region, the Service stays in <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.

AnnotationTypeDefaultDescription
publicbooltrueWhether the LB gets a public IP.
vpc-onlyboolfalseSynonym for public: "false".
public-ipstringautoReserve a specific public IP that you already own.
source-rangescsv CIDR0.0.0.0/0Allowed source CIDRs.
proxy-protocolv1/v2/boolfalseSend PROXY protocol header to backends. v2 binary, v1 text.
ssl-redirectboolfalseAuto 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-rulesJSON-SNI / path / host matching per port. See Routing rules.
traffic-splitJSON-Blue/green and canary traffic splitting across child Services. See Traffic splitting.
haboolfalseEnable HA (active-passive). Requires an HA-capable LB plan.
firewallbooltrueEnable LB firewall (otherwise wide-open on frontend ports).
backend-protocolenumtcptcp, http, or https. Picks L4 vs L7 mode.
security-groupscsv-Security groups to attach to the LB.
ssl-modeenumnonenone, letsencrypt, or inline.
ssl-domainstring-FQDN for the certificate.
ssl-certbase64-Inline mode only. Base64-encoded PEM fullchain.
ssl-keybase64-Inline mode only. Base64-encoded PEM private key.
health-check-*variessensible defaultsActive health probes (see Health checks).
passive-check-*variesoffMark 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-modeBehaviour
none (default)Pass through encrypted bytes. The LB does not see plaintext.
letsencryptThe platform auto-issues and auto-renews a free Let's Encrypt certificate.
inlineYou provide a PEM certificate and key inline.

Let's Encrypt

yaml
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:

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.

Provision first, then point DNS. Apply the Service with 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

yaml
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.

AnnotationValuesDefaultEffect
backend-protocoltcp, http, httpstcpLB frontend + backend mode. http/https enable L7 features.
yaml
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

Health checks #

The LB probes each worker NodePort on a schedule. Defaults are sensible - override only what you need.

AnnotationTypeDefaultDescription
health-check-enabledbooltrueDisable to stop probing entirely.
health-check-protocoltcp / httpmirrors backend-protocolProbe layer. http enables path + expect.
health-check-portint / traffic-portNodePortWhere to probe. traffic-port = same as live traffic.
health-check-pathstring/ (http only)HTTP path. Ignored for tcp.
health-check-intervalseconds5Probe frequency.
health-check-timeoutseconds3Per-probe timeout.
health-check-healthy-thresholdint2Consecutive OK probes before marked UP.
health-check-unhealthy-thresholdint3Consecutive failures before marked DOWN.
health-check-expectstringunsethttp only. e.g. status 200 or string OK.
yaml
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.

AnnotationTypeDefaultDescription
passive-check-enabledboolfalseObserve live traffic.
passive-check-error-limitint10Consecutive errors before action fires.
passive-check-on-errormark-down / fail-check / sudden-death / fastintermark-downWhat to do when the limit is hit.

Layer is picked automatically - L7 when backend mode is http, else L4.

Source range filtering #

yaml
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.

Kubernetes also has 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.

yaml
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

Public vs VPC-only #

By default the LB gets a public IP. To deploy the LB inside the VPC only:

yaml
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

yaml
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):

yaml
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:

ValueMeaning
"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.
yaml
metadata:
  annotations:
    service.beta.kubernetes.io/managed-loadbalancer-plan: std-1g
    service.beta.kubernetes.io/managed-loadbalancer-proxy-protocol: "v2"
Backends must parse the PROXY header or every request returns 502. Configure your pod's reverse proxy (nginx, traefik, envoy, haproxy) with 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.

AnnotationTypeDefaultDescription
ssl-redirectboolfalseAdd HTTP → HTTPS 301 on HTTP-mode frontends. Skips ACME challenge path so Let's Encrypt renewals still work.
yaml
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.

yaml
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

json
[
  {"port": 443, "match": "sni|path|host", "value": "...", "ssl_domain": "..."}
]
FieldRequiredDescription
portyesLB frontend port the rule applies to. Must exist in spec.ports.
matchyesOne of sni, path, host.
valueyesMatched value: domain for sni/host, URL prefix for path.
ssl_domainsni onlyPer-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.

yaml
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.

yaml
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.

yaml
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"}
      ]
Reconciliation: adding a rule object creates a new routing rule, removing one deletes it (and any Let's Encrypt cert tied to its 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.

FormAnnotationScopeMatch criteria
Standalonetraffic-splitEvery frontend port on the ServiceNone (catch-all)
Embeddedrouting-rules[].backendsOne specific routing ruleThe rule's match+value (SNI, path, host)

The mental model is split into two questions:

You can have one, the other, or both. The three valid combinations:

What you wantWhat to use
One backend per request, picked by SNI / path / hostrouting-rules alone
Weighted distribution across child Services, same everywheretraffic-split alone
Different hostnames AND each hostname needs a weighted splitrouting-rules with backends arrays inside each rule

Which form to use - decision tree

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
Common confusion: a 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.
Each child Service entry must include its 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.

yaml
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.

yaml
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.

AnnotationHAProxy modeWhen to use
(unset, default)tcpL4 passthrough, raw TCP, TLS passthrough (pods own the cert)
backend-protocol: httphttpLB 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:

Weight semantics & limits

Verifying the split: the cluster's Load Balancer page surfaces a "Traffic Split" section per frontend showing live weights and healthy backend counts. You can also drive traffic manually with 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.

yaml
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.

yaml
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.

yaml
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.

yaml
# 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

Operational notes

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.

yaml
# 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.

yaml
# 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.

yaml
# 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.

yaml
# 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.

yaml
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.

bash
kubectl edit svc webapp
# or:
kubectl annotate svc webapp \
  service.beta.kubernetes.io/managed-loadbalancer-source-ranges="10.0.0.0/8" \
  --overwrite

Errors & troubleshooting #

Failures show up as Kubernetes Events on the Service. Inspect with kubectl describe svc <name>:

bash
$ kubectl describe svc webapp
...
Events:
  Type     Reason                    Message
  ----     ------                    -------
  Warning  CreateLoadBalancerFailed  unknown_lb_plan: std-9xl
CodeMeaning
missing_annotationA required annotation is absent (usually plan).
unknown_lb_planThe plan name doesn't exist or has been disabled.
lb_plan_not_in_regionThe plan exists but isn't enabled in your cluster's region.
plan_not_ha_capableha: "true" was set but the plan doesn't support HA.
invalid_cidrA source-ranges entry is not a valid CIDR.
invalid_ssl_pemInline SSL cert/key failed parsing (likely not valid base64-encoded PEM).
ssl_dns_not_resolvedLet's Encrypt issuance is paused because DNS doesn't yet resolve to the LB.
ssl_dns_timeoutLet's Encrypt issuance failed - DNS didn't resolve within 24h.
static_ip_not_ownedpublic-ip references an IP not owned by your account.
static_ip_in_usepublic-ip is already assigned elsewhere.

Common situations

Ready to ship a Service?

Pick a recipe, paste it into a manifest, run kubectl apply.