TLS

How to serve the Horizon UI over HTTPS: create the certificate Secret, wire it into ui.ingress.tls, and tell Horizon it is now behind TLS so session cookies are marked Secure.

What terminates TLS

The chart does not serve HTTPS itself. The Horizon BFF container listens on plain HTTP (ui.service.internalPort, default 8081) and the Service forwards ui.service.externalPort (default 80) to it. TLS terminates at your ingress controller, using a Kubernetes TLS Secret that you reference from ui.ingress.tls.

A cloud load balancer can terminate it instead — ui.service.type: LoadBalancer plus a cert annotation in ui.service.annotations (values.yaml shows the AWS ACM one). That path skips the Ingress, so the chart derives no server.publicUrl for you and you set it in ui.config.server.publicUrl yourself; the rest of this page still applies.

So “enable TLS” is two steps that must both happen:

  1. Give the ingress a certificate (ui.ingress.tls).
  2. Tell Horizon it is being reached over https (ui.config.session.cookieSecure, and usually ui.config.server.trustProxy).

Step 2 is not automatic. Skipping it still leaves a working UI — the session cookie is SameSite=strict, so the browser keeps sending it over HTTPS — but the cookie carries no Secure flag and would go out in clear on any plain-HTTP request to the same host. See Mark session cookies Secure.

Create the TLS Secret

The Secret must live in the release namespace and be of type kubernetes.io/tls.

kubectl create secret tls skywalking-tls \
  -n "${SKYWALKING_RELEASE_NAMESPACE}" \
  --cert=path/to/tls.crt \
  --key=path/to/tls.key

If you run cert-manager, do not create the Secret by hand — name it in ui.ingress.tls[].secretName and let the issuer fill it in. The chart renders ui.ingress.annotations onto the Ingress verbatim, so the usual annotations work:

ui:
  ingress:
    enabled: true
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt-prod
    hosts:
      - skywalking.example.com
    tls:
      - secretName: skywalking-tls
        hosts:
          - skywalking.example.com

Wire ui.ingress.tls

ui.ingress.tls is passed through to the Ingress spec.tls list unchanged, so it takes the standard Kubernetes shape — a list of {secretName, hosts} entries. Default is [].

ui:
  ingress:
    enabled: true
    hosts:
      - skywalking.example.com
    tls:
      - secretName: skywalking-tls
        hosts:
          - skywalking.example.com

Rendering that gives:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-release-skywalking-helm-ui
spec:
  rules:
    - host: skywalking.example.com
      http:
        paths:
          - path: /
            backend:
              service:
                name: my-release-skywalking-helm-ui
                port:
                  number: 80
            pathType: Prefix
  tls:
    - hosts:
      - skywalking.example.com
      secretName: skywalking-tls

The hostnames in ui.ingress.tls[].hosts should match the entries in ui.ingress.hosts, or the controller will not find a certificate for the request’s Host.

What setting ui.ingress.tls changes on its own

One thing, and it is in the ConfigMap rather than the Ingress:

server.publicUrl written only when ui.ingress.enabled and ui.ingress.hosts is non-empty; derived from the first ui.ingress.hosts entry, with scheme https when ui.ingress.tls is non-empty and http otherwise

Horizon uses publicUrl to build SSO callbacks and as its OAuth issuer, so an http:// issuer on an HTTPS deployment breaks logins. Setting ui.ingress.tls flips it for you:

    server:
      port: 8081
      publicUrl: ${HORIZON_PUBLIC_URL:https://skywalking.example.com}

Everything else about TLS you set yourself.

Mark session cookies Secure

session.cookieSecure tells the BFF to set the Secure attribute on the session cookie. Horizon’s default is false, and the BFF logs a warning at boot when it is false outside development.

Set it to true once you serve over HTTPS:

ui:
  config:
    session:
      cookieSecure: true

Set HORIZON_SESSION_COOKIE_SECURE=true whenever the UI is served over HTTPS — browsers refuse a Secure cookie over plain HTTP, so without it the session cookie travels in the clear:

ui:
  extraEnv:
    - name: HORIZON_SESSION_COOKIE_SECURE
      value: "true"

If you still want the value overridable by env — for example to keep one values file for both an HTTP dev cluster and an HTTPS production one — write the token yourself, quoted so Helm keeps it a string:

ui:
  config:
    session:
      cookieSecure: "${HORIZON_SESSION_COOKIE_SECURE:true}"

which renders into horizon.yaml as an expandable token that still defaults to true:

    session:
      cookieSecure: ${HORIZON_SESSION_COOKIE_SECURE:true}

Why it matters

Browsers refuse to store or send a Secure cookie over plain HTTP. The two failure modes are symmetric:

Setting Served over Result
cookieSecure: true http:// Browser drops the cookie. Login “succeeds” and immediately bounces back to the login page.
cookieSecure: false https:// Login works, but the session cookie has no Secure flag and would be sent in clear on any HTTP request to the same host.

So flip cookieSecure in the same change that adds ui.ingress.tls, and flip it back if you drop to plain HTTP. A kubectl port-forward to http://localhost is the usual exception — most browsers treat localhost as a secure origin and keep the cookie — but if a port-forward login bounces you straight back to the login page, cookieSecure is the first thing to check.

Record the real client address

Behind an ingress, every request appears to come from the ingress. To make the login audit record the actual client, set server.trustProxy:

ui:
  config:
    server:
      trustProxy: 1        # one proxy in front; or an address / CIDR

Use a hop count (1 = a single proxy in front of the BFF) or the ingress address/CIDR. trustProxy: true is refused at boot — it would trust the whole X-Forwarded-For header, letting any caller choose the address that gets recorded. A /0 block is refused for the same reason, and a hostname is refused because the underlying server accepts only addresses.

server.trustProxy is read once when the HTTP server is constructed, so it takes effect only on pod restart.

Full example

# tls-values.yaml
ui:
  ingress:
    enabled: true
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt-prod
    hosts:
      - skywalking.example.com
    tls:
      - secretName: skywalking-tls
        hosts:
          - skywalking.example.com
  config:
    session:
      cookieSecure: true
    server:
      trustProxy: 1
helm upgrade --install "${SKYWALKING_RELEASE_NAME}" \
  oci://docker.io/apache/skywalking-helm \
  --version 5.0.0 \
  -n "${SKYWALKING_RELEASE_NAMESPACE}" \
  --set oap.image.tag=11.0.0 \
  --set oap.storageType=elasticsearch \
  --set ui.image.tag=horizon-1.0.0 \
  --set eck-operator.installCRDs=false \
  -f tls-values.yaml

helm upgrade does not carry --set values over from the previous release, so repeat every flag the original install used — the storage ones included. The flags above match the Elasticsearch install in Quick Start; a BanyanDB or PostgreSQL release has its own set (see Pick a Storage Backend).

Changing ui.config changes the UI ConfigMap, and the Deployment carries a checksum/config annotation over it, so the UI pod is recreated on upgrade. Horizon’s BFF keeps its session table in memory, so everyone logged in is logged out by that restart.

Verify

# The Ingress advertises the secret
kubectl get ingress -n "${SKYWALKING_RELEASE_NAMESPACE}" \
  -o jsonpath='{.items[*].spec.tls}'

# The Secret exists and is a TLS secret
kubectl get secret skywalking-tls -n "${SKYWALKING_RELEASE_NAMESPACE}" \
  -o jsonpath='{.type}'

# horizon.yaml has the https publicUrl and Secure cookies
kubectl get configmap "${SKYWALKING_RELEASE_NAME}-skywalking-helm-ui" \
  -n "${SKYWALKING_RELEASE_NAMESPACE}" \
  -o jsonpath='{.data.horizon\.yaml}'

# The certificate served on the wire
curl -vI https://skywalking.example.com 2>&1 | grep -i 'subject\|issuer'

What this page does not cover

  • TLS on the OAP endpoints (gRPC 11800, REST 12800) for agents. Those are plain Services, not an Ingress — see OAP Endpoints for Agents.
  • TLS between OAP and storage. Elasticsearch HTTP TLS is disabled by default in this chart (elasticsearch.http.tls.selfSignedCertificate.disabled: true) so OAP can connect without trusting the self-signed certificate — see Elasticsearch.