Configure Horizon
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.
Three mechanisms
| 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:
envbeatsenvFrom. The chart’s computed values areenventries, so a key of the same name in the Secret behindui.envFromSecretis ignored. Override a chart-computed value withui.extraEnvinstead.ui.extraEnvis 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.
What the chart sets
| 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_URLis 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_URLis conditional for a different reason — on OAP 10.x, port17128is the AI-pipeline URI-recognition server, not the admin REST host, so setoap.ports.admin: nullthere and the variable disappears.HORIZON_PUBLIC_URL’s scheme is derived per host, because atlsblock may cover only some of them. Atlsentry with nohostsis the controller’s default certificate and covers this one. An entry inui.ingress.hostsmay carry a path (skywalking.example.com/ui); the whole entry becomes the public URL, while only the hostname before the first/is matched againsttls[].hosts. SSO callbacks are built from that URL, so the path has to be the one the ingress actually serves the UI on.server.portfollowsui.service.internalPort. The container port comes from the same value, and both probes target the container’s namedpageport, so they follow it too. Do not overrideHORIZON_SERVER_PORTthroughui.extraEnv— the BFF would bind a port nothing routes to. Changeui.service.internalPort.server.hostis not set by the chart; the image’s ownENValready carriesHORIZON_SERVER_HOST=0.0.0.0.
Environment variables (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
Structured blocks take JSON in one variable
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.
Secrets (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 costs
Setting 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:
- A field you do not write there falls back to Horizon’s built-in default — not to the
:defaultin the image’s file, which is no longer present. - That field’s
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.
Horizon is not OAP
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.mode
live (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.
Persistence (/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.
Settings that take a path, not a value
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.mapfiles 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
Applying a change
| 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.
Next
- Horizon UI in This Chart — what the image is, and why booster UI is gone
- Set Up Logins — no login is configured by default
- Configure OAP — the same three mechanisms on the backend
- UI Service and Ingress — where
HORIZON_PUBLIC_URLcomes from - UI and Login Problems
- skywalking Chart values — every
ui.*value