Horizon‘s image ships a complete /app/horizon.yaml in which all 62 fields are ${HORIZON_*:default} placeholders, expanded over the raw text of that file before it is parsed as YAML. The chart mounts nothing over it. It sets the handful of values it can compute as plain environment variables and leaves the image’s file intact, so every other field stays settable from the environment.
Precedence is: env var → the file's :default → the built-in schema default.
| Value | Reach for it when | |
|---|---|---|
| 1. Environment variables | ui.extraEnv | Anything not sensitive. A list, so an entry may carry valueFrom |
| 2. A Secret's keys as environment variables | ui.envFromSecret | Password hashes, LDAP bind passwords, OAP credentials, API keys |
3. A horizon.yaml through a ConfigMap | ui.config | Pinning a field so no environment can change it — opt-in, and it replaces the image's file |
Prefer 1 and 2. They are how the image is meant to be configured, and they leave all 62 fields reachable. ui.config is {} by default: no ConfigMap is created and nothing is mounted until you set it.
Two rules govern how the two environment mechanisms combine, and both are plain Kubernetes:
env beats envFrom. The chart's computed values are env entries, so a key of the same name in the Secret behind ui.envFromSecret is ignored. Override a chart-computed value with ui.extraEnv instead.ui.extraEnv is appended after the computed entries, and for a duplicate name the last entry is the one the container sees. That is what makes such an override work.The field list itself is owned upstream: horizon.yaml reference.
| Environment variable | Value | Set when |
|---|---|---|
HORIZON_SERVER_PORT | ui.service.internalPort (8081) | always |
HORIZON_OAP_QUERY_URL | in-cluster OAP service + oap.ports.rest (12800) | always |
HORIZON_OAP_ADMIN_URL | in-cluster OAP service + oap.ports.admin (17128) | only when oap.ports.admin is set |
HORIZON_OAP_ZIPKIN_URL | in-cluster OAP service + oap.ports.zipkin-query + /zipkin | only when oap.ports.zipkin-query is set |
HORIZON_PUBLIC_URL | first entry of ui.ingress.hosts, https when a tls block covers that host, else http | only when ui.ingress.enabled and ui.ingress.hosts are set |
With chart defaults that is three variables. Render it yourself before installing:
helm template sw chart/skywalking \ --set oap.image.tag=11.0.0 \ --set oap.storageType=banyandb \ --set ui.image.tag=horizon-1.0.0 \ -s templates/ui-deployment.yaml
env: - name: HORIZON_SERVER_PORT value: "8081" - name: HORIZON_OAP_QUERY_URL value: "http://sw-skywalking-helm-oap:12800" - name: HORIZON_OAP_ADMIN_URL value: "http://sw-skywalking-helm-oap:17128"
Expose the Zipkin query port and a TLS ingress and the other two appear:
- name: HORIZON_OAP_ZIPKIN_URL value: "http://sw-skywalking-helm-oap:9412/zipkin" - name: HORIZON_PUBLIC_URL value: "https://skywalking.example.com"
Notes on the table:
HORIZON_OAP_ZIPKIN_URL is omitted rather than blanked when no Zipkin port is exposed: Horizon's schema requires a valid URL there, and an empty value fails at boot. HORIZON_OAP_ADMIN_URL is conditional for a different reason — on OAP 10.x, port 17128 is the AI-pipeline URI-recognition server, not the admin REST host, so set oap.ports.admin: null there and the variable disappears.HORIZON_PUBLIC_URL‘s scheme is derived per host, because a tls block may cover only some of them. A tls entry with no hosts is the controller’s default certificate and covers this one. An entry in ui.ingress.hosts may carry a path (skywalking.example.com/ui); the whole entry becomes the public URL, while only the hostname before the first / is matched against tls[].hosts. SSO callbacks are built from that URL, so the path has to be the one the ingress actually serves the UI on.server.port follows ui.service.internalPort. The container port comes from the same value, and both probes target the container's named page port, so they follow it too. Do not override HORIZON_SERVER_PORT through ui.extraEnv — the BFF would bind a port nothing routes to. Change ui.service.internalPort.server.host is not set by the chart; the image's own ENV already carries HORIZON_SERVER_HOST=0.0.0.0.ui.extraEnv)One variable per field, appended to the container's env:
ui: extraEnv: - name: HORIZON_TEMPLATES_MODE value: readonly - name: HORIZON_SESSION_COOKIE_SECURE value: "true" - name: HORIZON_TRUST_PROXY value: "1" - name: HORIZON_OAP_QUERY_URL # overrides the chart's computed value value: http://oap.observability.svc:12800
It is a list, so an entry may take its value from a single Secret key or the downward API rather than from the values file:
ui: extraEnv: - name: HORIZON_OAP_AUTH valueFrom: secretKeyRef: name: horizon-oap key: auth.json
Fields that are objects or lists rather than scalars take their whole block as JSON in a single variable. A :null default in the shipped file means “fall through to the built-in default”.
| Variable | Field |
|---|---|
HORIZON_AUTH_LOCAL_USERS | auth.local.users |
HORIZON_AUTH_LDAP | auth.ldap |
HORIZON_AUTH_BREAK_GLASS | auth.breakGlass — honored only when backend=ldap and the LDAP probe is failing |
HORIZON_AUTH_SSO | auth.sso |
HORIZON_RBAC_ROLES | rbac.roles |
HORIZON_RBAC_LANDING_BY_ROLE | rbac.landingByRole — post-login landing route per role |
HORIZON_OAP_AUTH | oap.auth — basic-auth for the BFF's outbound calls to OAP |
HORIZON_OAP_MQE | oap.mqe — host/port override, defaults to the query host |
HORIZON_PERFORMANCE | performance — BFF→OAP fan-out and caps |
HORIZON_LAYERS_EXCLUDED | layers.excluded — an array of {key, reason}; [] surfaces every reported layer |
HORIZON_AUDIT_POSTGRES | audit.postgres — connection settings for the sign-in audit; a secret, so keep it in ui.envFromSecret |
HORIZON_AI_STARTERS | ai.starters |
HORIZON_OAUTH_CLIENT_METADATA_HOSTS | oauth.clientMetadataHosts |
Each variable carries the whole block, replacing it rather than merging into it — so restate the parts of the default you want to keep. layers.excluded defaults to FAAS and VIRTUAL_GATEWAY, which is why hiding one more means naming all three:
ui: extraEnv: - name: HORIZON_LAYERS_EXCLUDED value: '[{"key":"FAAS"},{"key":"VIRTUAL_GATEWAY"},{"key":"SO11Y_OAP","reason":"Internal."}]' - name: HORIZON_PERFORMANCE value: '{"bulk":{"dashboard":{"bulkSize":8}}}'
The value is injected into the file's text and parsed there, so it must be a single flow value — one line, or continuation lines indented under the first. A newline at column zero ends the value and breaks the parse.
ui.envFromSecret)ui.envFromSecret names a pre-created Secret and becomes an envFrom.secretRef on the UI container, so every key of it arrives as an environment variable. One Secret carries everything sensitive ($HASH below is an Argon2id password hash — Set Up Logins mints one):
kubectl create secret generic horizon-secrets \ -n "${SKYWALKING_RELEASE_NAMESPACE}" \ --from-literal=HORIZON_AUTH_LOCAL_USERS='[{"username":"admin","passwordHash":"'"$HASH"'","roles":["admin"]}]' \ --from-literal=HORIZON_OAP_AUTH='{"username":"skywalking","password":"changeme"}'
ui: envFromSecret: horizon-secrets
That is the whole configuration. Nothing has to be written into ui.config for those variables to be read — the tokens they fill are already in the image's file.
ui.config, and what it costsSetting ui.config creates a ConfigMap named {release}-skywalking-helm-ui (key horizon.yaml) and mounts it read-only as a subPath over /app/horizon.yaml. That replaces the image's file rather than merging with it, which has two consequences:
:default in the image’s file, which is no longer present.HORIZON_* variable stops working, because the token it would have expanded is gone. Silently.The chart merges its computed values back in, so OAP stays reachable either way. The OAP URLs and server.publicUrl go in as ${VAR:default} tokens and stay env-overridable. server.port does not — it is written as a literal, because the container port and both probes come from ui.service.internalPort and the BFF has to bind the same one, so HORIZON_SERVER_PORT is the one computed value ui.config really does make inert:
helm template sw chart/skywalking \ --set oap.image.tag=11.0.0 \ --set oap.storageType=banyandb \ --set ui.image.tag=horizon-1.0.0 \ --set ui.config.templates.mode=readonly \ -s templates/ui-configmap.yaml
data: horizon.yaml: | oap: adminUrl: ${HORIZON_OAP_ADMIN_URL:http://sw-skywalking-helm-oap:17128} queryUrl: ${HORIZON_OAP_QUERY_URL:http://sw-skywalking-helm-oap:12800} server: port: 8081 templates: mode: readonly
templates.mode is now pinned: HORIZON_TEMPLATES_MODE does nothing for this deployment, which is exactly the point of writing it there. The same goes for any other field you put in ui.config.
Fields you leave out fall back to Horizon's built-in defaults, and their HORIZON_* variables have no token to fill — with one exception the chart handles for you. auth.local.users keeps its ${HORIZON_AUTH_LOCAL_USERS:[]} token unless you write users yourself, so a deployment taking its users from a Secret does not lose them the moment ui.config is set. The OAP URLs are preserved the same way.
ui: envFromSecret: horizon-secrets config: templates: mode: readonly auth: local: users: ${HORIZON_AUTH_LOCAL_USERS:[]}
Every field you want to stay env-settable needs its token restated like that, which is the whole reason to prefer mechanisms 1 and 2 and to keep ui.config down to what must be pinned.
Both components offer the same three mechanisms, but the balance between them is different, because the two images read configuration differently:
Horizon (ui.*) | OAP (oap.*) | |
|---|---|---|
| Environment variables | ui.extraEnv (list) | oap.env (map, no valueFrom) and oap.extraEnv (list) |
| From a Secret | ui.envFromSecret | oap.envFromSecret — applied to the OAP Deployment and the init Job |
| Files | ui.config — a last resort | oap.config — the only way to supply some things |
Horizon's entire configuration is one env-tokenized file the image already ships, so a file mount buys nothing but the ability to pin. OAP reads real files: log4j2.xml, the OAL and MAL rule sets, metadata-service-mapping.yaml. Its application.yml resolves settings as ${SW_*:default} the same way, so environment variables cover settings — but nothing except a mounted file can supply a rule set, so oap.config stays a first-class mechanism there.
Use oap.extraEnv where oap.env cannot reach: it is a list, so entries may carry valueFrom for a single credential out of a Secret or a value from the downward API. oap.envFromSecret covers the init Job as well, which needs the same storage credentials as the Deployment. See Configure OAP.
templates.modelive (Horizon‘s default) reads and writes dashboard templates through OAP 11’s /ui-management/templates* admin REST API and persists them in OAP storage. In that mode OAP is the only source: if the template store cannot be read, layer pages are blocked rather than falling back to the bundled templates.
Against OAP 10.x you must set readonly. OAP 10 does not serve that REST surface, so live blocks every layer-driven page. readonly renders the templates bundled in the image and makes the configuration surface display-only; dashboards, traces, logs, topology, alarms and profiling all work.
ui: extraEnv: - name: HORIZON_TEMPLATES_MODE value: readonly
Changing the mode requires a BFF restart, not just a config reload.
/data)The image declares /data as its state volume and routes the BFF's OAP wire debug log there (HORIZON_WIRE_LOG_FILE=/data/horizon-wire.jsonl, written only when debugLog.enabled); anything else you point at a path under /data lands there too. The chart always mounts a volume at /data — an emptyDir by default, so that state is gone whenever the pod is replaced (upgrade, reschedule, delete). Turn on a PVC for anything you intend to keep:
ui: persistence: enabled: true size: 1Gi # storageClass: standard # existingClaim: my-horizon-data
| Value | Default | Notes |
|---|---|---|
ui.persistence.enabled | false | false → emptyDir; true → PVC mounted at /data |
ui.persistence.existingClaim | unset | use a pre-created PVC; otherwise the chart creates {release}-skywalking-helm-ui-data |
ui.persistence.storageClass | unset | - renders an empty storageClassName |
ui.persistence.accessModes | [ReadWriteOnce] | matches ui.replicas: 1 |
ui.persistence.size | 1Gi | |
ui.persistence.annotations | {} | applied to the chart-managed PVC |
The image runs as the non-root horizon user, so any volume mounted into the container must be group-writable by it. ui.securityContext.fsGroup defaults to 101 for exactly this reason — keep it (or set an equivalent) when you override ui.securityContext, and apply the same thought to anything you add through ui.extraVolumeMounts.
Keep ui.replicas: 1. The BFF holds its session table in memory, the Deployment uses the Recreate strategy for that reason, and a ReadWriteOnce PVC cannot be mounted by pods on two different nodes anyway.
Two Horizon 1.0.0 settings name a file or directory rather than carrying a value, so they need ui.extraVolumes / ui.extraVolumeMounts — but the path itself is still an ordinary variable:
auth.tokensFile (HORIZON_AUTH_TOKENS_FILE) — API tokens for callers with no browser (scripts, CI, MCP clients). Empty by default; mount a Secret and point the variable at it.sourceMaps.bootMountDir (HORIZON_SOURCEMAPS_DIR) — durable .map files for the Browser Errors tab. The image already sets it to /app/sourcemaps, so only the volume is missing; without one, runtime uploads live in BFF memory and are lost on pod restart.ui: extraVolumes: - name: horizon-tokens secret: secretName: horizon-tokens extraVolumeMounts: - name: horizon-tokens mountPath: /app/tokens readOnly: true extraEnv: - name: HORIZON_AUTH_TOKENS_FILE value: /app/tokens/tokens.json
| What you changed | Does the pod roll? |
|---|---|
ui.extraEnv, or a value feeding a computed variable | Yes — the pod spec changed, so helm upgrade rolls it |
The contents of the Secret behind ui.envFromSecret | No — no pod field changed |
ui.config | Yes — the Deployment carries a checksum/config annotation over the rendered ConfigMap |
Environment is read once at process start, so a Secret edit needs a restart you ask for yourself:
kubectl rollout restart -n "${SKYWALKING_RELEASE_NAMESPACE}" \ deploy/${SKYWALKING_RELEASE_NAME}-skywalking-helm-ui
The checksum/config annotation exists only while ui.config is set, and it is there because a subPath ConfigMap mount never updates inside a running container.
Verify what actually reached the container:
# what the chart and your values set kubectl exec -n "${SKYWALKING_RELEASE_NAMESPACE}" \ deploy/${SKYWALKING_RELEASE_NAME}-skywalking-helm-ui -- env | grep '^HORIZON_' | sort # the file being expanded: the image's 62 tokens, unless ui.config is set kubectl exec -n "${SKYWALKING_RELEASE_NAMESPACE}" \ deploy/${SKYWALKING_RELEASE_NAME}-skywalking-helm-ui -- cat /app/horizon.yaml
For whether authentication took, /api/auth/health reports it without a login — see Set Up Logins.
HORIZON_PUBLIC_URL comes fromui.* value