Skip to content

Exposing KubeManta — operator guide

KubeManta is two Services:

Service Default type Port Traffic
<release>-ui ClusterIP 3000 Everything — the Next.js dashboard, and the HTTP API proxy (/api/agent/* is forwarded to the agent in-cluster by Next.js)
<release>-agent ClusterIP 8080 Only the terminal WebSocket path /api/agent/terminal/ws

Which mode do you want?

Your situation Mode
Nothing exists yet; let the chart create an Ingress 1
You run Traefik / HAProxy / Contour and want the chart to write the Ingress 2
You have a shared Ingress or an edge (WAF, Cloudflare tunnel) and will wire it yourself 3
You want Kubernetes to create a cloud LB, optionally on a reserved IP 4
A load balancer already exists OUTSIDE the cluster (F5, HAProxy VM, hand-built ALB, another team's LB) 5
No load balancer and no ingress controller at all — bare metal, must be public 6
Internal-only, but still HTTPS — reachable from the VPC/office, never the internet 7
You standardised on Gateway API and already run a Gateway Gateway API

Mode 5 is the one people mean by "connect it to our existing load balancer", and it has three variants — 5A (LB → your ingress controller, no NodePort, recommended), 5B (LB → pods directly via TargetGroupBinding / standalone NEG / CIS, no NodePort), and 5C (LB → NodePort, last resort).

Worked examples for the three major clouds, each covering routing, health checks, the WebSocket timeout, firewalls, trustedProxyCidrs, and both public and private schemes:

Cloud Existing LB Attach via
AWS Application Load Balancer TargetGroupBinding, target-type: ip
GCP HTTP(S) Load Balancer standalone zonal NEGs
Azure Application Gateway v2 backend pools of pod IPs, or AGIC

The one routing rule that must never be broken

/api/agent/terminal/ws (prefix match) must reach the AGENT Service directly; everything else goes to the UI Service.

The Next.js App Router cannot upgrade HTTP to WebSocket, so if the terminal path lands on the UI Service the browser PTY terminal silently fails. Every mode below wires this for you except "attach to an existing ingress", where you configure it yourself.

The terminal WebSocket is long-lived — whatever edge you use needs a read/idle timeout of ≥ 3600s on that path (the chart pre-sets this for gce/alb/nginx/AGIC).

CRITICAL — security.trustedProxyCidrs

Whenever ANY proxy/LB/edge terminates client connections in front of KubeManta (all four modes below except a direct LoadBalancer Service with no proxy), you MUST set:

--set security.trustedProxyCidrs="<your edge's egress CIDR(s), comma-separated>"

Otherwise every request is attributed to the proxy IP instead of the real client, which: - collapses audit attribution (every actor becomes the proxy IP), and - lets one client's failed logins auto-ban the shared edge IP for everyone.

Also set --set security.cookieSecure=true when you serve KubeManta over HTTPS through your own edge with no global.hostname set (the auto-detect can't see your edge's TLS).


Mode 1 — Chart-managed Ingress (default)

The chart creates the Ingress (plus GCE-only extras) when global.hostname and an ingress class are set. Cloud-tuned classes:

# GKE external L7 (Google-managed cert, reserved global static IP):
--set global.hostname=km.example.com \
--set global.ingressClassName=gce \
--set global.staticIpName=my-reserved-ip

# GKE INTERNAL L7 (regional static IP; Google-managed certs are NOT supported
# on the internal LB — supply your own TLS secret):
--set global.hostname=km.internal.example.com \
--set global.ingressClassName=gce-internal \
--set global.staticIpName=my-regional-ip \
--set global.tlsSecret=my-tls-secret

# EKS ALB + ACM:
--set global.hostname=km.example.com \
--set global.ingressClassName=alb \
--set global.acmCertificateArn=arn:aws:acm:...:certificate/...

# AKS Application Gateway (AGIC):
--set global.hostname=km.example.com \
--set global.ingressClassName=azure-application-gateway

GCE-specific objects (BackendConfig ×2, ManagedCertificate, NEG/static-ip annotations) render only for gce/gce-internal.

Mode 2 — BYO ingress controller (any class)

Use your installed controller — Traefik, HAProxy, Contour, Istio, anything with an IngressClass. The chart renders a standard networking.k8s.io/v1 Ingress with both backends wired (terminal-WS → agent, rest → UI) and cert-manager TLS auto-detect (cert-manager.io/cluster-issuer: letsencrypt-prod by default; override global.certManagerIssuer, or set global.tlsSecret to use a certificate you already hold).

ingress-nginx reached end of life in March 2026 — no releases, no bugfixes and no security patches. The chart still emits its annotations for ingress.className=nginx so existing installs keep working during migration, but do not choose it for a new deployment.

--set global.hostname=km.example.com \
--set ingress.className=traefik        # or global.ingressClassName; ingress.className wins
  • ingress.className — any class; overrides global.ingressClassName.
  • ingress.annotations — arbitrary passthrough, merged last (overrides the chart's class defaults). Use it for your controller's WebSocket/timeout knobs. nginx is pre-set by the chart (proxy-read-timeout/proxy-send-timeout: 3600, proxy-http-version: 1.1); for others add e.g.:
  • traefik: no annotation usually needed (WebSocket native); pin an entrypoint with traefik.ingress.kubernetes.io/router.entrypoints: websecure
  • HAProxy: haproxy.org/timeout-tunnel: "3600s"
  • Contour: projectcontour.io/response-timeout: "3600s"
  • ingress.extraHosts — extra hostnames on the same Ingress (each gets the same two path rules); ingress.extraTls — extra spec.tls entries, appended verbatim.
  • Set security.trustedProxyCidrs to the narrowest range that contains your ingress-controller pods — ideally a /32 per pod, or the controller's own Service/pod subnet. Not the whole pod CIDR. See the warning below.
helm upgrade ... \
  --set global.hostname=km.example.com \
  --set ingress.className=traefik \
  --set-string 'ingress.annotations.traefik\.ingress\.kubernetes\.io/router\.entrypoints=websecure' \
  --set security.trustedProxyCidrs="10.244.3.17/32,10.244.5.42/32"   # your controller pods

⚠ Do not trust the whole pod CIDR

Everything inside trustedProxyCidrs is believed when it sets X-Forwarded-For. Trusting 10.244.0.0/16 therefore trusts every pod in the cluster — and the chart ships no ingress NetworkPolicy on the agent Service, so by default any pod can reach it.

A workload that can open a TCP connection to the agent then chooses: - the actor recorded against every audited action — so your audit trail attributes an attacker's writes to whatever address they type; - which IP the allowlist/denylist and ban decisions apply to; - which IP the durable failed-login counter increments — so one tenant can spoof failures as another team's office IP until that shared egress address is auto-banned for everyone behind it.

External XFF injection is already handled correctly (the value is only read when the socket peer is itself trusted, and the rightmost untrusted address is taken). This exposure is entirely in-cluster, and it comes from over-broad configuration rather than from the header handling.

Earlier revisions of this page recommended the pod CIDR. Corrected 2026-07-29. If you followed that advice, narrow the value — no reinstall needed.

(Use --set-string for annotation values that look numeric.)

Mode 3 — Service-only: attach to an EXISTING ingress / your own edge

The chart creates no Ingress at all — you wire the two Services into an edge you already run (existing shared Ingress, hardware LB, WAF, Cloudflare tunnel, ...):

--set ingress.enabled=false \
--set security.trustedProxyCidrs="<edge egress CIDRs>" \
--set security.cookieSecure=true        # if your edge serves HTTPS

Configure exactly two backends on your edge:

Match Backend
path prefix /api/agent/terminal/ws <release>-agent:8080 — WebSocket upgrade + ≥3600s read/idle timeout
everything else (/) <release>-ui:3000

Example snippet for an existing shared nginx Ingress:

- host: km.example.com
  http:
    paths:
      - path: /api/agent/terminal/ws
        pathType: Prefix
        backend: { service: { name: kubemanta-agent, port: { number: 8080 } } }
      - path: /
        pathType: Prefix
        backend: { service: { name: kubemanta-ui, port: { number: 3000 } } }

Service types/ports are overridable: service.type / ui.service.type, and service.nodePort / ui.service.nodePort to pin stable NodePorts on the agent and UI respectively (see Mode 5C).

Mode 4 — Attach to an existing / reserved cloud LoadBalancer

Expose one or both Services as type: LoadBalancer and let your cloud controller attach the LB. New first-class fields (all optional, absent from the manifest when unset):

Value (also under ui.service.*) Purpose
service.loadBalancerIP Pin a reserved IP (GCP/Azure/MetalLB; AWS uses EIP annotations instead)
service.loadBalancerClass BYO LB implementation (e.g. MetalLB, service.k8s.aws/nlb)
service.loadBalancerSourceRanges Cloud-enforced client CIDR firewall
service.annotations Cloud-specific LB behavior (recipes below)
--set ingress.enabled=false \
--set ui.service.type=LoadBalancer \
--set ui.service.loadBalancerIP=203.0.113.10 \
--set 'ui.service.loadBalancerSourceRanges={10.0.0.0/8}' \
--set service.type=LoadBalancer          # agent LB only if the terminal must be reachable via this path

If you expose ONLY the UI Service via the LB, the terminal will not work through it (the WS path must reach the agent). Either expose both Services, or front them with a path-routing edge (Modes 1–3, or Gateway below).

AWS NLB (recommended for the long-lived terminal WebSocket; the AWS Load Balancer Controller must be installed):

--set service.type=LoadBalancer \
--set-string 'service.annotations.service\.beta\.kubernetes\.io/aws-load-balancer-type=external' \
--set-string 'service.annotations.service\.beta\.kubernetes\.io/aws-load-balancer-nlb-target-type=ip' \
--set-string 'service.annotations.service\.beta\.kubernetes\.io/aws-load-balancer-scheme=internal'

GCP internal LB:

--set-string 'service.annotations.networking\.gke\.io/load-balancer-type=Internal'

Azure internal LB:

--set-string 'service.annotations.service\.beta\.kubernetes\.io/azure-load-balancer-internal=true'

A direct LB with no proxying edge is the one case where trustedProxyCidrs stays empty (the socket peer IS the client — with NLB/ILB in pass-through mode; consult your LB's docs on client-IP preservation, and use externalTrafficPolicy-aware setups where needed).

Mode 5 — Attach to a load balancer you already run, OUTSIDE the cluster

Modes 1–4 all assume Kubernetes is in charge of the edge. This mode is for the case where a load balancer already exists and is managed outside the cluster — an F5 or Citrix appliance, an HAProxy/nginx VM, a hand-built AWS ALB, a corporate LB another team owns. Nothing in Kubernetes created it and nothing in Kubernetes will reconfigure it.

Pick the variant that matches your constraints. Most people should use 5A.

Point the external LB at the ingress controller you already run, and let KubeManta stay ClusterIP. The LB never talks to KubeManta directly, so there is no NodePort, no port to firewall, and nothing to re-pin when a Service is re-created.

[ existing external LB ] → [ your ingress controller ] → [ kubemanta-ui / -agent Services ]

Configure KubeManta exactly as Mode 2 (BYO ingress class) or Mode 3 (service-only plus a manual Ingress rule), then set the LB's backend pool to the ingress controller's existing address. The two path rules in Mode 3 are the whole KubeManta-specific part.

--set ingress.enabled=false \
--set security.trustedProxyCidrs="<ingress controller pod addresses>" \
--set security.cookieSecure=true

This is the recommended shape because it changes nothing about how your edge already works — KubeManta becomes one more backend behind the ingress controller that already fronts everything else.

5B — Your LB targets pods directly (no NodePort, cloud-native)

If your LB controller can bind to pods, you can attach KubeManta's Services to a target group/backend that already exists, with no NodePort and no chart-created LB.

Platform Mechanism
AWS TargetGroupBinding (AWS Load Balancer Controller) binds a Service to a target group you created by hand. Use target-type: ip so the ALB/NLB addresses pods directly.
GCP Standalone NEGs — annotate the Service with cloud.google.com/neg: '{"exposed_ports": {"8080":{}}}', then add the resulting NEG to your existing backend service.
Azure AGIC, or an application-gateway backend pool of pod IPs (requires Azure CNI, not kubenet).
F5 Container Ingress Services (CIS) targets pods directly from an existing virtual server.
On-prem MetalLB gives type: LoadBalancer an address from a pool you control — service.loadBalancerIP pins it.

Two KubeManta-specific points:

  1. Bind BOTH Services, or route by path. …-ui:3000 for everything, and …-agent:8080 for /api/agent/terminal/ws. Binding only the UI leaves the terminal dead — see the routing rule at the top of this document.
  2. GCP conflict to watch: the chart already sets cloud.google.com/neg: '{"ingress": true}' on the agent Service when the GCE ingress class is in use. Use ingress.enabled=false for standalone NEGs, or your annotation will fight the chart's.

Use service.annotations / ui.service.annotations to add whatever your controller requires — they are passed through verbatim.

5B-AWS — Worked example: an ALB that already exists

The common case: an ALB is already deployed and managed outside the cluster (Terraform, another team, a shared edge), and KubeManta has to become a backend on it. Nothing here creates or modifies the ALB itself.

Use an ALB, not an NLB, if you want one load balancer. KubeManta needs path-based routing — the terminal WebSocket path must reach a different Service from everything else. An ALB does that with listener rules. An NLB is L4 and cannot path-route at all, so with an NLB you must either expose two separate ports/listeners or put an ingress controller behind it (5A).

1. Two target groups, target-type: ip

Target group Port Protocol Health check
kubemanta-ui 3000 HTTP /login
kubemanta-agent 8080 HTTP /healthz

Health-check paths matter. /login is public and returns 200. Anything auth-gated answers a 307 redirect, which the ALB scores as unhealthy — the symptom is a 502 with both pods running perfectly. /healthz is the agent's unauthenticated liveness endpoint. (/readyz is deliberately non-gating and always returns 200, so do not use it to decide target health.)

target-type: ip addresses pods directly through the VPC CNI — no NodePort. The pods must be routable from the ALB's subnets, which they are on EKS with the default VPC CNI.

2. Listener rules — order matters

Priority Condition Forward to
10 path is /api/agent/terminal/ws or /api/agent/terminal/ws/* kubemanta-agent
default everything else kubemanta-ui

The terminal rule must have the lower priority number (evaluated first). If the default rule wins, the WebSocket lands on the UI Service, Next.js cannot upgrade it, and the terminal fails silently with no error in any log.

3. Raise the idle timeout

aws elbv2 modify-load-balancer-attributes \
  --load-balancer-arn <your-alb-arn> \
  --attributes Key=idle_timeout.timeout_seconds,Value=3600

The ALB default is 60 seconds. The terminal WebSocket is long-lived, so at the default an idle shell drops after a minute. ALB supports WebSockets natively over HTTP/1.1 — no other switch is needed. (4000s is the ALB maximum.)

4. Bind the Services to the existing target groups

Requires the AWS Load Balancer Controller in the cluster. This is the only Kubernetes object you add, and it does not create an LB — it keeps the existing target groups' targets in sync with the Services' pods.

apiVersion: elbv2.k8s.aws/v1beta1
kind: TargetGroupBinding
metadata:
  name: kubemanta-ui
  namespace: kubemanta-system
spec:
  serviceRef: { name: kubemanta-ui, port: 3000 }
  targetGroupARN: arn:aws:elasticloadbalancing:REGION:ACCT:targetgroup/kubemanta-ui/…
---
apiVersion: elbv2.k8s.aws/v1beta1
kind: TargetGroupBinding
metadata:
  name: kubemanta-agent
  namespace: kubemanta-system
spec:
  serviceRef: { name: kubemanta-agent, port: 8080 }
  targetGroupARN: arn:aws:elasticloadbalancing:REGION:ACCT:targetgroup/kubemanta-agent/…

5. Install KubeManta with no edge of its own

Build the command on the install page — choose "I already have one" for the load balancer — and it produces exactly these edge flags:

  --set ingress.enabled=false \
  --set security.cookieSecure=true \
  --set security.trustedProxyCidrs="10.0.101.0/24,10.0.102.0/24"   # ALB SUBNET CIDRs

trustedProxyCidrs must be the CIDRs of the subnets the ALB runs in — the ALB's own addresses live there, and those are the socket peers KubeManta sees. Get them with:

aws elbv2 describe-load-balancers --load-balancer-arns <arn> \
  --query 'LoadBalancers[0].AvailabilityZones[].SubnetId' --output text

Without this, every request is attributed to the ALB: audit attribution collapses to one address, and one user's failed logins auto-ban the ALB for everybody. With it, KubeManta reads the real client from X-Forwarded-For.

6. Security groups

The ALB's security group must be allowed outbound to the pod/node CIDR on 3000 and 8080, and the cluster's node/pod security group must allow inbound from the ALB's security group on those ports. A target stuck in unhealthy with correct health-check paths is almost always this.


Public (internet-facing) vs private (internal)

The KubeManta configuration is identical in both cases — the difference is entirely the ALB's own scheme and where its subnets live.

Public Private
ALB scheme internet-facing internal
ALB subnets public (route to an internet gateway) private
Reached from the internet, via DNS to the ALB inside the VPC — VPN, Direct Connect, Transit Gateway, peered VPC
TLS ACM certificate on an HTTPS:443 listener ACM cert, or plain HTTP if the whole path is already private
security.cookieSecure true true if the listener is HTTPS; false if genuinely plain HTTP
security.trustedProxyCidrs ALB subnet CIDRs ALB subnet CIDRs (same rule)

If the ALB is internet-facing, also read If your load balancer is public below — the guidance is the same on all three clouds, so it is stated once.

5B-GCP — Worked example: an HTTP(S) Load Balancer that already exists

Attach via standalone zonal NEGs: GKE creates the NEGs, you add them as backends to a backend service that already exists. No NodePort, and the chart creates no LB.

1. Annotate both Services to emit standalone NEGs

kubectl annotate svc kubemanta-ui -n kubemanta-system \
  cloud.google.com/neg='{"exposed_ports": {"3000": {"name": "kubemanta-ui-neg"}}}'

kubectl annotate svc kubemanta-agent -n kubemanta-system \
  cloud.google.com/neg='{"exposed_ports": {"8080": {"name": "kubemanta-agent-neg"}}}'

Install with ingress.enabled=false. When the chart manages a gce/gce-internal Ingress it sets cloud.google.com/neg: '{"ingress": true}' on the agent Service itself; with the Ingress disabled that annotation is not rendered, so the slot is yours. You can also pass these through service.annotations / ui.service.annotations instead of kubectl annotate, which survives upgrades.

GKE creates one NEG per zone. Add every zonal NEG to the backend service, or traffic to pods in the missing zones simply fails.

2. Two backend services, wired into the existing URL map

Backend service NEG Health check
kubemanta-ui-backend kubemanta-ui-neg (all zones) HTTP /login
kubemanta-agent-backend kubemanta-agent-neg (all zones) HTTP /healthz

URL-map path matcher on your existing map:

Path rule Backend service
/api/agent/terminal/ws, /api/agent/terminal/ws/* kubemanta-agent-backend
default kubemanta-ui-backend

3. Backend timeout for the WebSocket

gcloud compute backend-services update kubemanta-agent-backend --global \
  --timeout=3600

The backend-service timeout applies to WebSocket connections as a maximum connection duration, and the default is 30s.

4. Firewall — the GCP-specific gotcha

Google's load balancer and health checkers reach your backends from two fixed ranges. Allow them to the pod/node ports, or every target is permanently unhealthy:

gcloud compute firewall-rules create allow-gfe-to-kubemanta \
  --network=<vpc> --action=allow --direction=ingress \
  --source-ranges=130.211.0.0/22,35.191.0.0/16 \
  --rules=tcp:3000,tcp:8080

5. Install, and set trustedProxyCidrs to the right thing

This differs between the external and internal load balancer, and getting it wrong silently collapses audit attribution.

# EXTERNAL (public) HTTP(S) LB — traffic arrives from the Google Front Ends:
--set ingress.enabled=false --set security.cookieSecure=true \
--set security.trustedProxyCidrs="130.211.0.0/22,35.191.0.0/16"

# INTERNAL HTTP(S) LB — traffic arrives from the PROXY-ONLY SUBNET, not the GFEs:
--set ingress.enabled=false --set security.cookieSecure=true \
--set security.trustedProxyCidrs="<your proxy-only subnet CIDR>"

Find the proxy-only subnet with:

gcloud compute networks subnets list --filter="purpose:REGIONAL_MANAGED_PROXY"
Public Private
LB type global external Application LB regional internal Application LB
Needs a proxy-only subnet no yes, one per region
TLS Google-managed or self-managed cert self-managed cert (Google-managed certs are not supported on the internal LB)
trustedProxyCidrs 130.211.0.0/22,35.191.0.0/16 the proxy-only subnet CIDR

5B-Azure — Worked example: an Application Gateway that already exists

Attach the Services to backend pools on an Application Gateway (v2) you already run. Requires Azure CNI — pod IPs must be routable from the gateway's subnet, which kubenet does not give you.

1. Two backend pools of pod IPs

Backend pool Targets Port
kubemanta-ui-pool UI pod IPs 3000
kubemanta-agent-pool agent pod IPs 8080

If you would rather not manage pod IPs by hand, install AGIC and point it at the existing gateway (appgw.subscriptionId / resourceGroup / name) — AGIC keeps the pools in sync from Ingress objects. Either way the chart creates nothing: install with ingress.enabled=false.

2. Health probes

Probe Path For
kubemanta-ui-probe /login UI pool — public, returns 200
kubemanta-agent-probe /healthz agent pool

Do not probe an authenticated path: it answers 307 and the gateway marks the pool unhealthy while both pods are perfectly fine.

3. Path-based routing rule

Path Backend pool
/api/agent/terminal/ws* kubemanta-agent-pool
/* (default) kubemanta-ui-pool

4. Raise the backend request timeout

Application Gateway v2 supports WebSockets natively, but the HTTP setting's Request time-out defaults to 20 seconds — an idle terminal drops almost immediately. Raise it on the agent's backend HTTP setting:

az network application-gateway http-settings update \
  --gateway-name <gw> --resource-group <rg> \
  --name kubemanta-agent-settings --timeout 3600

5. Install

Build the command on the install page — choose "I already have one" for the load balancer — and add:

  --set ingress.enabled=false \
  --set security.cookieSecure=true \
  --set security.trustedProxyCidrs="<application gateway SUBNET CIDR>"

The gateway lives in its own dedicated subnet, and that subnet's CIDR is what KubeManta sees as the socket peer:

az network application-gateway show --name <gw> --resource-group <rg> \
  --query 'gatewayIPConfigurations[0].subnet.id' -o tsv
Public Private
Frontend IP public IP configuration private IP configuration only
Reached from the internet inside the VNet — VPN, ExpressRoute, peered VNet
NSG on the gateway subnet must allow Azure infrastructure ports 65200-65535 (v2 requirement) same
trustedProxyCidrs gateway subnet CIDR gateway subnet CIDR (same rule)

If your load balancer is public — on any cloud

The KubeManta login page is now reachable from the internet. That is supported, and it is a deliberate exposure decision rather than a default:

  • Set an IP allowlist (Admin → Access Control) to the office/VPN ranges you expect. Read what the IP allowlist can and cannot see in the Features → Overview page first — private ranges are exempt by default for anti-lockout reasons.
  • Put a WAF in front. KubeManta's IP filtering is defense in depth, not a WAF: no L7 pattern detection, no DDoS absorption, no geo-fencing. AWS WAF, Cloud Armor and Azure WAF all attach at the load balancer.
  • Redirect HTTP to HTTPS and keep security.cookieSecure=true.
  • Narrow the load balancer's own firewall to expected client CIDRs rather than the whole internet, wherever that is possible.

An internal load balancer reached over VPN is the lower-risk default if your operators are already on one.

5C — Your LB targets node ports (the NodePort variant)

Only if 5A and 5B are unavailable — an appliance that can only address host:port, and no ingress controller to hide behind.

--set ingress.enabled=false \
--set ui.service.type=NodePort --set ui.service.nodePort=31000 \
--set service.type=NodePort    --set service.nodePort=31080 \
--set security.trustedProxyCidrs="<LB egress addresses>" \
--set security.cookieSecure=true

Then, on the LB:

LB rule Backend pool
path prefix /api/agent/terminal/ws every node :31080 — WebSocket upgrade, ≥3600s idle timeout
everything else every node :31000

Pin both ports. An allocated NodePort changes if the Service is re-created, and the LB's backend pool does not notice — the symptom is a working install that goes dark after an unrelated helm upgrade. (service.nodePort for the agent was added 2026-07-30; only the UI Service supported pinning before, which made this mode impossible to wire correctly.)

Why this is the last resort: a NodePort opens the port on every node, it is reachable by anything that can route to a node, and it bypasses whatever policy your ingress controller enforces. Many organizations forbid it outright. If yours does, use 5A.

Mode 6 — No load balancer at all: bare metal, made public

The case: your own certificate and private key, no cloud load balancer, nothing in front, and it has to be reachable from the internet.

KubeManta cannot do this alone, and that is worth stating plainly. Neither pod terminates TLS — the UI serves plain HTTP on 3000 and the agent on 8080. The chart writes an Ingress object; it does not ship an ingress controller. With no controller installed, that Ingress is inert: nothing reads it, and nothing answers on 443.

So you need exactly two things the chart does not provide: something to terminate TLS, and a route from the outside world to it.

1. Install an ingress controller

helm repo add traefik https://traefik.github.io/charts
helm install traefik traefik/traefik -n traefik --create-namespace \
  --set service.type=LoadBalancer

Do not reach for ingress-nginx

ingress-nginx was retired in March 2026 — no releases, no bugfixes and no security patches, on the component that terminates TLS at the edge of your cluster. InGate, its intended successor, was abandoned before it matured. Traefik is used here because it is maintained; HAProxy, Contour and the commercial NGINX controller are equally valid. The chart still emits nginx.ingress.kubernetes.io/* annotations for ingress.className=nginx so an existing install keeps working while you migrate — that is a migration aid, not a recommendation.

2. Give the controller a reachable address

service.type=LoadBalancer stays <pending> forever on bare metal — there is no cloud controller to assign an address. Pick one:

Option How Trade-off
MetalLB (recommended) Install MetalLB, give it a pool containing your routable IP Real LoadBalancer support; survives node loss with BGP or L2 failover
NodePort --set service.type=NodePort on the controller, then point DNS/firewall at <node-ip>:<nodeport> Ports 30000–32767, so you need a port-forward at the router for 443
hostNetwork --set hostNetwork=true on the controller Binds 443 directly on the node; that node becomes a single point of failure

Then point a public DNS A record for your hostname at that address.

3. Load your certificate and install

The Secret must live in KubeManta's namespace — an Ingress can only reference a Secret in its own namespace:

kubectl create namespace kubemanta-system

kubectl create secret tls km-tls \
  --cert=fullchain.pem --key=privkey.pem \
  -n kubemanta-system

Build the install command on the install page — choose Existing TLS Secret — which produces:

  --set global.hostname=km.example.com \
  --set ingress.className=traefik \
  --set global.tls=true \
  --set global.tlsSecret=km-tls \
  --set security.cookieSecure=true \
  --set security.trustedProxyCidrs="<controller pod CIDR>"

trustedProxyCidrs is not optional here: the controller is now the socket peer for every request, so without it every action is attributed to the controller instead of the user, and one client's failed logins auto-ban the shared address for everybody. See CRITICAL — security.trustedProxyCidrs.

4. Check the certificate you actually served

curl -vI https://km.example.com 2>&1 | grep -E 'subject|issuer|SSL certificate'
openssl s_client -connect km.example.com:443 -showcerts </dev/null

A leaf-only chain is the usual failure: it validates in a browser that already cached the intermediate and fails everywhere else, so it gets reported as "works on my machine" rather than as a certificate fault.


Mode 7 — Internal-only, but still HTTPS

The case: reachable from your VPC, VPN or office — never from the internet — but still served over TLS, because internal traffic is still traffic and the session cookie is still a credential.

The shape is Mode 6 with the address made private. TLS termination is identical; only reachability changes.

Choosing the certificate

This is the part that catches people, and it is not a KubeManta constraint:

Certificate Works internally? Note
Internal CA / corporate PKI Yes Every client must already trust the CA — usually true on managed devices
Public CA via DNS-01 Yes Let's Encrypt with a DNS-01 challenge issues for a name that never resolves publicly
Public CA via HTTP-01 No The CA must reach the host over the internet. An internal-only host cannot answer, so issuance fails
Self-signed Technically Browser warnings on every visit; users learn to click through security warnings, which is its own risk

Making the address private

Annotate the ingress controller's Service so the NLB is internal:

helm install traefik traefik/traefik -n traefik --create-namespace \
  --set service.type=LoadBalancer \
  --set service.annotations."service\.beta\.kubernetes\.io/aws-load-balancer-scheme"=internal
helm install traefik traefik/traefik -n traefik --create-namespace \
  --set service.type=LoadBalancer \
  --set service.annotations."networking\.gke\.io/load-balancer-type"=Internal

Or use the chart's own gce-internal class for an internal L7 LB (--set global.ingressClassName=gce-internal), which also supports a regional reserved address via global.staticIpName.

helm install traefik traefik/traefik -n traefik --create-namespace \
  --set service.type=LoadBalancer \
  --set service.annotations."service\.beta\.kubernetes\.io/azure-load-balancer-internal"=true

Give MetalLB a pool of private addresses, or leave the controller on NodePort and simply do not port-forward it at the router. Restrict further with loadBalancerSourceRanges.

Restrict who can reach it

Two independent layers, and they fail differently:

# 1. cloud-enforced, on the CONTROLLER's Service — traffic never reaches a node
--set service.loadBalancerSourceRanges="{10.0.0.0/8,192.168.0.0/16}"
# 2. in-product, on KubeManta — an allowlist evaluated before authentication
#    Admin -> Access Control, or seeded at install

Layer 1 is enforced by the cloud and cannot be bypassed by anything in the cluster. Layer 2 is defense-in-depth and, deliberately, fails open on a config error — a lockout that cannot be undone from outside is worse than a brief gap. Loopback and RFC1918 are permanently exempt so kubectl port-forward can never be locked out. Neither layer is a WAF.

Install

Identical to Mode 6 — the chart neither knows nor cares whether the address in front of it is public or private:

  --set global.hostname=km.internal.example.com \
  --set ingress.className=traefik \
  --set global.tls=true \
  --set global.tlsSecret=km-tls \
  --set security.cookieSecure=true \
  --set security.trustedProxyCidrs="<controller pod CIDR>"

Keep security.cookieSecure=true. It is about TLS, not about the internet: you are serving HTTPS, so the session cookie should be marked Secure.

Just need a quick look, with no edge at all?

kubectl port-forward svc/kubemanta-ui -n kubemanta-system 3001:3000 needs no Ingress, no controller and no certificate. It is plain HTTP over an authenticated tunnel to the API server, so pair it with --set security.cookieSecure=false. Fine for evaluation, not a deployment model — it is one user, one session, on one workstation.


Gateway API (HTTPRoute) — attach to an existing Gateway

For clusters standardizing on Gateway API, the chart can attach an HTTPRoute to a Gateway you already run (the chart never creates the Gateway or its CRDs; requires gateway.networking.k8s.io/v1):

--set ingress.enabled=false \
--set ingress.gateway.enabled=true \
--set ingress.gateway.parentRefs[0].name=my-gateway \
--set ingress.gateway.parentRefs[0].namespace=infra \
--set ingress.gateway.parentRefs[0].sectionName=https   # optional listener pin

Hostnames default to [global.hostname]; override with ingress.gateway.hostnames. The route sends /api/agent/terminal/ws to the agent and everything else to the UI (Gateway API picks the most specific path match natively). Set security.trustedProxyCidrs to your Gateway implementation's pod/egress CIDRs. If your implementation enforces an idle timeout, raise it on the Gateway/listener (implementation-specific — there is no portable HTTPRoute timeout for WebSockets).


Upgrade safety

All new values are nil-guarded: an existing install upgraded with --reuse-values (no ingress.className/gateway/loadBalancer* keys in the stored values) renders byte-for-byte identical manifests. The render-test suite (helm/tests/test_helm_render.py) pins this.

This document covers traffic coming in to KubeManta. For traffic going out — a corporate forward proxy, or no external egress at all — see the Network Egress page.