Container Image
Horizon UI ships a single multi-arch container image (linux/amd64 + linux/arm64). The image bundles both the BFF and the built UI — there is no separate frontend container.
Where to get it
Registry: GitHub Container Registry (GHCR) at ghcr.io/apache/skywalking-horizon-ui.
| Tag | Points at | Use case |
|---|---|---|
<40-char-sha> |
Exact commit. Immutable. | Production. Pin to a SHA so deploys are reproducible. |
vX.Y.Z |
Tagged release. | Stable release. Same image as the SHA it was built from. |
X.Y |
Latest patch on a minor line. Moves over time. | Track a minor release line. |
latest |
Newest vX.Y.Z tag. Moves. |
Demos / dev only — do not pin production to latest. |
main |
Head of main. Moves on every merge. |
Smoke-test the development branch. |
docker pull ghcr.io/apache/skywalking-horizon-ui:v1.2.3
docker pull ghcr.io/apache/skywalking-horizon-ui:<sha>
The full commit SHA is the canonical, immutable identifier. Moving tags are conveniences that point at the same SHA-built image.
Image layout
| Path inside the container | Owner | Writable by horizon? |
What it is |
|---|---|---|---|
/app/dist/server.js |
root | no | Compiled BFF entry point. CMD runs node dist/server.js. |
/app/node_modules/ |
root | no | Production npm dependencies. |
/app/static/ |
root | no | Built UI assets (Vite dist/). |
/app/horizon.example.yaml |
root | no | Example config — read-only reference, copy from it. |
/app/horizon.yaml |
n/a | n/a | Where the BFF expects the active config. Not present in the image — provide via mount or COPY (see below). |
/app/bundled_templates/ |
horizon | yes | Layer + overview JSON templates. Owned by horizon because the admin Layer-Templates and Overview-Templates editors write into per-key files here. |
/data/ |
horizon | yes | Declared VOLUME. Default destination for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / named volume / host bind here for durable storage. |
The runtime stage runs as the non-root user horizon. Two locations are owned by horizon so writes work without operator intervention: bundled_templates/ (so the admin editors save) and /data/ (so state files land somewhere durable).
Environment variables
| Variable | Default in image | Purpose |
|---|---|---|
NODE_ENV |
production |
Drives the logger format (JSON vs pretty) and Node optimizations. |
LOG_LEVEL |
(unset → error in production, debug in dev) |
Pino log level: trace, debug, info, warn, error, fatal. |
HORIZON_CONFIG |
/app/horizon.yaml |
Where the BFF looks for horizon.yaml. Override to mount elsewhere. |
HORIZON_STATIC_DIR |
/app/static |
Where the BFF serves UI assets from. |
HORIZON_AUDIT_FILE |
/data/horizon-audit.jsonl |
Default for audit.file when horizon.yaml doesn’t override it. |
HORIZON_SETUP_FILE |
/data/horizon-setup.json |
Default for setup.file. |
HORIZON_ALARMS_FILE |
/data/horizon-alarms.json |
Default for alarms.file. |
HORIZON_WIRE_LOG_FILE |
/data/horizon-wire.jsonl |
Default for debugLog.file. |
The four HORIZON_*_FILE env vars seed the defaults the config schema uses when horizon.yaml doesn’t supply a value. An explicit value in horizon.yaml always wins. The intent: an operator who runs the published image with only a minimal horizon.yaml (no audit/setup/alarms/debugLog blocks) gets state files routed to /data/ automatically, no manual path overrides needed.
server.host and server.port come from the YAML — not from env vars. The image sets EXPOSE 8081; if you change server.port, also publish the new port.
How to load horizon.yaml into the container
Three common approaches.
1. Bind-mount from the host
Simplest for single-host deployments. Mount your horizon.yaml at /app/horizon.yaml:
docker run -d \
--name horizon \
-p 8081:8081 \
-v "$PWD/horizon.yaml:/app/horizon.yaml:ro" \
ghcr.io/apache/skywalking-horizon-ui:v1.2.3
Notes:
:ro— read-only mount. The BFF only reads the file; preventing writes catches mistakes.server.hostin your YAML should be0.0.0.0, not the default127.0.0.1— otherwise the BFF only binds the container’s loopback and-p 8081:8081cannot reach it.
2. Bake it in (custom image)
For immutable single-tenant deployments, build a child image that includes your config:
FROM ghcr.io/apache/skywalking-horizon-ui:v1.2.3
COPY horizon.yaml /app/horizon.yaml
Pros: one artifact contains both code and config. Cons: rebuild on every config change; secrets baked into image layers.
If you bake it in, do not include secrets directly. Use ${ENV_VAR} interpolation in the YAML (see horizon.yaml Reference) and pass the actual secrets via env at run time.
3. Kubernetes ConfigMap + Secret
Standard pattern for Kubernetes deployments. The non-secret YAML goes in a ConfigMap; secrets stay in Secret resources and are injected as env vars.
apiVersion: v1
kind: ConfigMap
metadata:
name: horizon-config
data:
horizon.yaml: |
server:
host: 0.0.0.0
port: 8081
oap:
queryUrl: http://oap.skywalking:12800
adminUrl: http://oap.skywalking:17128
auth:
username: skywalking
password: "${HORIZON_OAP_PW}"
auth:
backend: local
local:
users:
- username: admin
passwordHash: "${HORIZON_ADMIN_HASH}"
roles: [admin]
rbac:
enabled: true
session:
cookieSecure: true
---
apiVersion: v1
kind: Secret
metadata:
name: horizon-secrets
type: Opaque
stringData:
HORIZON_OAP_PW: "..."
HORIZON_ADMIN_HASH: "$argon2id$v=19$..."
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: horizon
spec:
replicas: 1
selector:
matchLabels: { app: horizon }
template:
metadata:
labels: { app: horizon }
spec:
securityContext:
# The image's `horizon` user is created by `adduser -S`, which
# picks a system UID. `fsGroup` makes the mounted volumes
# group-writable by that user without a chown sidecar.
fsGroup: 101
containers:
- name: horizon
image: ghcr.io/apache/skywalking-horizon-ui:v1.2.3
ports:
- containerPort: 8081
envFrom:
- secretRef: { name: horizon-secrets }
volumeMounts:
- name: config
mountPath: /app/horizon.yaml
subPath: horizon.yaml
readOnly: true
- name: state
mountPath: /data
readinessProbe:
httpGet: { path: /api/oap/info, port: 8081 }
periodSeconds: 10
volumes:
- name: config
configMap:
name: horizon-config
items: [{ key: horizon.yaml, path: horizon.yaml }]
- name: state
persistentVolumeClaim:
claimName: horizon-state
Notes:
subPath: horizon.yamlmounts the single file rather than the directory, so it doesn’t shadow/app’s other contents.- Mount with
readOnly: trueon the config — the BFF only reads it. /datais the image’s declaredVOLUMEfor runtime state (audit log, setup, alarms, wire debug). The fourHORIZON_*_FILEenv vars baked into the image point at/data/*, so mounting a PVC here is enough — no path overrides inhorizon.yamlare required.fsGroup: 101is the typical alpinenobodyGID thatadduser -S -G horizon horizonfalls into. Rundocker run --rm <image> id horizonto confirm if you’ve forked the image.- Run a single replica unless you accept that sessions are per-pod (the in-memory session store does not federate; see session).
Persisting state files (audit, setup, alarms, debugLog)
The BFF writes runtime state to JSON Lines / JSON files. The image declares /data as a VOLUME and points the four defaults at /data/* via env vars, so no horizon.yaml configuration is required to get writable, persistable paths — operators just mount a volume at /data:
docker run -d --name horizon \
-p 8081:8081 \
-v "$PWD/horizon.yaml:/app/horizon.yaml:ro" \
-v horizon-state:/data \
ghcr.io/apache/skywalking-horizon-ui:v1.2.3
Without a mounted volume the writes still land in the container’s writable layer at /data/ (ephemeral, but at least non-failing). Mounting a volume is what makes them durable.
If you want to override the locations, you can either:
-
Set the env vars:
-e HORIZON_AUDIT_FILE=/var/log/horizon/audit.jsonl ..., or -
Set the paths explicitly in
horizon.yaml:audit: { file: /var/log/horizon/audit.jsonl } setup: { file: /var/lib/horizon/setup.json } alarms: { file: /var/lib/horizon/alarms.json } debugLog: { file: /var/log/horizon/wire.jsonl }
In either case the target directory must be writable by the horizon user. Storage classes that enforce ownership need fsGroup set in Kubernetes (or chown on bind mounts) to match the horizon UID/GID inside the container.
Persisting admin-edited templates
The Layer-Templates and Overview-Templates admin editors write into /app/bundled_templates/. The image’s bundled_templates/ directory is owned by the horizon user so saves work out of the box — but it is inside the image layer, meaning admin edits are lost on container replacement.
To persist admin-edited templates across container restarts / image updates:
- Copy the bundled templates out of the image once:
docker cp <container>:/app/bundled_templates ./bundled_templates. - Mount that directory back in:
-v "$PWD/bundled_templates:/app/bundled_templates".
The mounted directory must be writable by the horizon user (UID/GID inside the container — check with docker run --rm <image> id horizon). Without persistence, admin edits behave as ephemeral overrides — useful for try-it-out, destructive for production.
Logging
The BFF uses pino and writes structured JSON to stdout in production — visible via docker logs <container> and ready for any log aggregator (Fluent Bit, Vector, Promtail, Filebeat, Datadog) without extra parsers.
| Mode | How to enter | Output |
|---|---|---|
| Production | The image sets NODE_ENV=production. Anything that isn’t explicitly NODE_ENV=development is treated as production — including the local binary node dist/server.js. |
One JSON object per line on stdout. Default level error — quiet by default; only warnings, errors, and fatals reach stdout. Fields: level, time, pid, hostname, plus per-event keys (reqId, req, res, responseTime, msg, …). |
| Development | pnpm --filter bff dev (the dev script sets NODE_ENV=development explicitly). |
Pretty-printed, colorized, with timestamps via pino-pretty. Default level debug — full lifecycle chatter + per-request access logs. Human-readable. |
Adjust the floor with LOG_LEVEL when triaging:
docker run -e LOG_LEVEL=info ... # add per-request access logs + lifecycle
docker run -e LOG_LEVEL=debug ... # add the loader / capability-probe chatter
docker run -e LOG_LEVEL=trace ... # every pino-instrumented site
docker run -e LOG_LEVEL=warn ... # even quieter than the default
NODE_ENV=production LOG_LEVEL=info node dist/server.js
Per-request logging
Fastify’s request logger is on by default and emits one incoming request line + one request completed line per HTTP request, both tagged with a stable reqId. These are level-info (30) events — suppressed under the production default error. Bump to LOG_LEVEL=info to surface them; example pair under that level:
{"level":30,"time":1779109372598,"pid":1,"hostname":"...","reqId":"req-1","req":{"method":"GET","url":"/api/auth/health","host":"127.0.0.1:8081","remoteAddress":"192.168.65.1","remotePort":60655},"msg":"incoming request"}
{"level":30,"time":1779109372614,"pid":1,"hostname":"...","reqId":"req-1","res":{"statusCode":200},"responseTime":14.93,"msg":"request completed"}
Genuine request errors (5xx, request-handler exceptions) are still logged at error (50) — they reach stdout under any default that includes error.
This is separate from the audit log (which records sensitive operations — login, rule edits, break-glass — to a JSONL file at audit.file; see Access Control → Audit Log) and the wire-debug log (which records OAP HTTP request/response payloads when debugLog.enabled: true; see Setup → debugLog). Three orthogonal channels:
| Channel | Where | What | Toggle |
|---|---|---|---|
| App logs | stdout (JSON in prod, pretty in dev) | Lifecycle + per-request | Always on. LOG_LEVEL adjusts. |
| Audit log | audit.file (JSONL) |
Logins, RBAC-gated mutations | Always on. Path = audit.file. |
| Wire-debug | debugLog.file (JSONL) |
Outbound OAP requests/responses | Off by default. debugLog.enabled: true opt-in. |
Aggregating from Docker
# Quick tail with severity color (jq):
docker logs -f horizon-test | jq -c '. | "\(.time|todate) [\(.level)] \(.msg)"' -r
# Just request failures:
docker logs -f horizon-test | jq -c 'select(.res.statusCode >= 400)'
# Just structured slowness:
docker logs -f horizon-test | jq -c 'select(.responseTime != null and .responseTime > 200)'
For Kubernetes, the standard pipelines (fluent-bit tail plugin with Parser json, or vector kubernetes_logs → parse_json) ingest these lines directly. No app-side configuration required.
Network
- Container exposes 8081 by default. If you change
server.port, publish the new port and update the readiness probe. - The BFF needs egress to:
- OAP query port (default 12800).
- OAP admin port (default 17128).
- OAP Zipkin port (default 9412) if any layer uses
traces.source: zipkinorboth. - LDAP server (default 636 / 389) if
auth.backend: ldap.
See Network Ports for the full port matrix.
Multi-arch
The image is built for both linux/amd64 and linux/arm64. Docker auto-selects the right architecture on pull. For Apple Silicon dev hosts, the arm64 image runs natively (no emulation).
TLS
The image does not terminate TLS. Always run behind a TLS terminator (Kubernetes Ingress, Nginx, Envoy, a cloud LB). Once TLS is in front:
session:
cookieSecure: true
so session cookies are flagged Secure and the browser refuses to send them over plain HTTP.
Building locally
The image is built from the Dockerfile in the repo root. Same docker buildx invocation as CI:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t horizon:local \
.
For a single-arch dev build (faster):
docker build -t horizon:local .
docker run --rm -it -p 8081:8081 \
-v "$PWD/horizon.yaml:/app/horizon.yaml:ro" \
horizon:local
Health probes
Wire your platform’s readiness probe to one of:
| Endpoint | What it verifies |
|---|---|
GET /api/oap/info |
BFF is up and OAP query port is reachable. Strict — readiness gates on OAP. |
GET /api/auth/health |
BFF is up + auth backend is healthy. Useful if you want readiness independent of OAP. |
| TCP probe on 8081 | BFF process is listening. Loosest — does not verify OAP wiring. |
Liveness probes should be TCP-only (the BFF process is up). Wiring OAP into liveness creates a cascade failure when OAP blips.
Common mistakes
server.host: 127.0.0.1inside the container. Listener binds loopback only;-pcannot route traffic in. Set0.0.0.0.- Mounting
horizon.yamlas a directory.docker run -v "$PWD:/app/horizon.yaml"mounts the whole working directory and shadows/app. Always mount the file path, not the directory. - State files lost on container replacement. The image’s defaults route state files to
/data/, which is declared as aVOLUMEbut is ephemeral unless you bind / mount-PVC it. Mount a durable volume at/data(or override the paths viaHORIZON_*_FILEenv vars). - Forking the image without preserving
/app/bundled_templatesownership. The imagechowns this dir tohorizonso admin saves work. A naiveCOPY --from=base /app /appin a child image resets ownership to root → admin Layer-Templates / Overview-Templates saves EACCES. Either re-chownin your child image, or mount your own writable directory at/app/bundled_templates. - Secrets in baked config. Use
${ENV_VAR}interpolation and pass actual secrets via env. Anything in a built image layer is recoverable by anyone who pulls the image. - Pinning
latestin production.latestmoves silently; an automaticpullrolls you onto a new version without notice. Pin a SHA. - Multi-replica without sticky sessions. Sessions are in-memory per BFF process. Multi-replica without sticky routing breaks logins on every other request.