How to run the Vantage Studio image and how to override its
configuration. For the YAML schema itself see
configure.md; for users / RBAC / audit see
auth.md.
ghcr.io/wu-sheng/vantage-studio:<tag>
| Tag | Source | Use |
|---|---|---|
vX.Y.Z / latest |
release.yml on vX.Y.Z git tag |
Production. Cosign-signed, CycloneDX SBOM attestation. |
sha-<short> / sha-<full> |
ci.yml on every push to main |
Tracking head. Cosign-signed; same provenance as the release. |
main |
ci.yml on every push to main |
Floating "latest main commit." Same artifact as sha-<short>. |
Verify a pulled image (any tag) before running it in production:
cosign verify ghcr.io/wu-sheng/vantage-studio:<tag> \
--certificate-identity-regexp 'https://github.com/wu-sheng/vantage-studio/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comMulti-stage build, distroless runtime (gcr.io/distroless/nodejs24-debian12:nonroot):
- BFF entry:
/app/server.js(esbuild bundle ofapps/bff/src/index.ts) - SPA bundle:
/app/ui/(vitedist) - First-run config seed:
/app/studio.yaml.example - Production
node_modules(carriesargon2's linux-x64 prebuild +pino's worker tree)
No shell, no package manager, no root user — nonroot is uid 65532.
| Path / port | What |
|---|---|
:8080 |
Studio HTTP — SPA + /api/*. Configurable via server.listen. |
/data/ |
Default mount point for runtime state (config + audit + debug log). |
/data/studio.yaml |
The single config file — STUDIO_CONFIG defaults here. |
/data/audit.jsonl |
Default audit.file; one JSON line per actor-initiated event. |
/data/debug-wire.jsonl |
Default debugLog.file when enabled. |
The container runs as nonroot (uid 65532) so any host directory you bind-mount under /data/ must be writable by that uid (or be a Docker volume — Docker handles ownership for those).
Three knobs the container reads directly. Everything else is in
studio.yaml.
| Env var | Default | Notes |
|---|---|---|
STUDIO_CONFIG |
/data/studio.yaml |
Path the BFF reads + watches. If the file doesn't exist at boot, Studio copies the example seed to this path and continues — useful for first-run with a Docker volume. |
STUDIO_CONFIG_EXAMPLE |
/app/studio.yaml.example |
Seed copied to STUDIO_CONFIG on first run if the target is missing. Set this to your own example to seed a different default. Most operators leave it as-is and mount their own studio.yaml over the default path instead. |
STUDIO_UI_DIR |
/app/ui |
Where the BFF serves the SPA bundle from. Don't change unless you've replaced the bundle in a derived image. |
There is no per-field env-var override for studio.yaml keys
today. All configuration goes through the YAML file. To inject
secrets without baking them into the image, mount them as files —
see Layering secrets on top below.
The base case. Edit your config on the host, bind-mount it read-only over the default path:
docker run -d --name studio --restart unless-stopped \
-p 8080:8080 \
-v $PWD/studio.yaml:/data/studio.yaml:ro \
-v studio-state:/data \
ghcr.io/wu-sheng/vantage-studio:<tag>The named studio-state volume holds audit.jsonl (and
debug-wire.jsonl when enabled). The studio.yaml bind-mount sits
on top of that volume and is read-only — Studio's watcher only ever
reads.
The watcher (chokidar) picks up edits, file replacements, and
ConfigMap-style atomic-rename updates. Hot reloads — successful
edits take effect within ~1 s, no container restart. Invalid edits
log a warning and the previous valid config keeps serving, so a
typo doesn't take Studio down.
Useful when you have multiple environments' configs in one directory:
docker run -d --name studio \
-p 8080:8080 \
-e STUDIO_CONFIG=/etc/studio/prod.yaml \
-v $PWD/configs:/etc/studio:ro \
-v studio-state:/data \
ghcr.io/wu-sheng/vantage-studio:<tag>/data/ still holds audit.jsonl (per audit.file default) — the
config path doesn't have to live alongside it.
The image only reads one file, but you can compose it from multiple sources by writing the merged file in an init step. Two common patterns:
Mount the base config + the secret as separate read-only files,
concatenate them into a tmpfs at startup. Hot-reload still works
because the merged file lives in the same path the watcher follows.
# docker-compose.yml (excerpt)
services:
studio:
image: ghcr.io/wu-sheng/vantage-studio:<tag>
user: 65532:65532
environment:
STUDIO_CONFIG: /run/studio/studio.yaml
volumes:
- ./base.yaml:/secrets/base.yaml:ro
- ./secrets.yaml:/secrets/secrets.yaml:ro
- studio-tmp:/run/studio # tmpfs in k8s
- studio-state:/data
entrypoint:
- /bin/sh
- -c
- |
cat /secrets/base.yaml /secrets/secrets.yaml > /run/studio/studio.yaml
exec /nodejs/bin/node /app/server.js
volumes:
studio-tmp:
studio-state:This entrypoint trick assumes you've baked a small init layer on top
of the distroless runtime — distroless itself has no /bin/sh. The
typical fix is a 2-stage image:
FROM ghcr.io/wu-sheng/vantage-studio:<tag> AS studio
FROM busybox:musl AS init
COPY --from=studio /app /app
COPY --from=studio /nodejs /nodejs
USER 65532
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["cat /secrets/*.yaml > /run/studio/studio.yaml && exec /nodejs/bin/node /app/server.js"]Mount the non-sensitive config from a ConfigMap and the
auth.local.users[].passwordHash from a Secret as files in the
same path. The watcher picks up either when its source rotates:
apiVersion: v1
kind: ConfigMap
metadata:
name: studio-base
data:
studio.yaml: |
server: { listen: 0.0.0.0:8080 }
oap:
adminUrls: [http://oap-svc:17128]
statusUrl: http://oap-svc:12800
auth:
backend: local
local:
users: [] # filled in by the Secret-mounted overlay below
rbac:
enabled: true
roles:
admin: { verbs: ['*'] }
---
apiVersion: v1
kind: Secret
metadata:
name: studio-users
stringData:
studio.yaml: |
auth:
local:
users:
- username: alice
passwordHash: $argon2id$...
roles: [admin]
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: studio }
spec:
template:
spec:
securityContext: { runAsNonRoot: true, runAsUser: 65532 }
initContainers:
- name: merge
image: busybox:musl
command:
- sh
- -c
- cat /src/base/studio.yaml /src/users/studio.yaml > /run/studio/studio.yaml
volumeMounts:
- { name: base, mountPath: /src/base }
- { name: users, mountPath: /src/users }
- { name: runtime, mountPath: /run/studio }
containers:
- name: studio
image: ghcr.io/wu-sheng/vantage-studio:<tag>
env:
- { name: STUDIO_CONFIG, value: /run/studio/studio.yaml }
ports: [{ containerPort: 8080 }]
volumeMounts:
- { name: runtime, mountPath: /run/studio }
- { name: state, mountPath: /data }
volumes:
- { name: base, configMap: { name: studio-base } }
- { name: users, secret: { secretName: studio-users } }
- { name: runtime, emptyDir: { medium: Memory } }
- { name: state, persistentVolumeClaim: { claimName: studio-state } }The initContainer writes the merged studio.yaml into a tmpfs
emptyDir before the main container starts. ConfigMap and Secret
edits propagate to the running pod via kubelet's atomic-rename;
Studio's watcher sees the change and hot-reloads.
Why merge instead of two
STUDIO_CONFIGs? The BFF reads one file. Adding a second-source overlay is on the roadmap (aSTUDIO_CONFIG_OVERLAYenv var that deep-merges) but isn't in v0.1; until it lands, an init-container merge is the cleanest production pattern.
The repo ships deploy/docker/docker-compose.yml
that brings up Studio + OAP + BanyanDB:
make compose-up # docker compose -f deploy/docker/docker-compose.yml up --build
make compose-down # docker compose ... down --volumesVisit http://localhost:8080 · login admin / vantage-changeme.
Replace the password by editing studio.yaml in the studio-data
volume — the watcher hot-reloads.
The compose stack expects an OAP image tagged locally as
apache/skywalking-oap-server:admin-server from the SWIP-13 branch.
See install.md for the OAP build step.
- NetworkPolicy. Studio is the only authenticated client of the
OAP admin port (
17128). Deny everything except the Studio pod from reaching it. Same applies to every admin-server-hosted surface (/runtime/rule/*,/runtime/oal/*,/dsl-debugging/*). - TLS termination. Set
session.cookieSecure: trueonce HTTPS is in front. If the proxy addsX-Forwarded-For, setserver.trustProxy: trueso audit entries log the real caller IP. - Persistence. A small PVC under
/datais enough —audit.jsonlis the only thing that grows. Daily rotation is your job (logrotatesidecar, k8s log shipper that tails the file, or fluent-bit). - Healthcheck. None baked into the image yet. A liveness probe on
GET /api/auth/mereturning401is good enough — the route exists and is fast;401is the unauth response, which proves the server is up and responding. - Resource shape. Single Node.js process, no in-memory rule state. ~120 MB RSS at idle, ~250 MB under a busy debug-session poll. CPU is bursty per request and otherwise near-zero. One replica per cluster is enough — if you want HA, pin to one (active) replica + run a second one cold; v1 sessions are in-memory so a failover is a re-login.
| Event | What happens |
|---|---|
Edit studio.yaml |
chokidar fires; new YAML is parsed + zod-validated; on success it replaces the in-memory config. No restart. Sessions, vue-query caches in connected SPAs, and audit log handles all carry over. |
| Save invalid YAML | Parse / schema error logged to stderr. Previous valid config keeps serving. No restart, no downtime. |
| Restart the BFF | All in-memory sessions drop — every operator has to log in again. Audit log keeps appending to the same file. |
| Replace the container | Same as restart, plus any tmpfs-backed merged config has to be re-merged by the init container. |
| Image upgrade (rolling deploy) | Plan for a brief 503 between pod terminations. SPA refetches /api/auth/me; if the session map is gone, mid-session 401 → SPA hard-redirects to /login. |
configure.md— everystudio.yamlknob.auth.md— local users, optional RBAC, audit log, deferred LDAP / OIDC plan.compatibility.md— required SkyWalking version- OAP module selectors.
install.md— compose quick-start + production install hints.