Recipe: Deploy a live dashboard
This recipe takes a report bundle that already builds and puts it on the network as an interactive
web application, using bino serve inside the official container image. It goes from a single
docker run to a Compose stack to a Kubernetes Deployment with health probes.
Prerequisites
Section titled “Prerequisites”- A bino report bundle with at least one working
ReportArtefact. - The container image — see Running bino in Docker.
- A reverse proxy for anything reachable beyond your laptop (see TLS and access control).
bino serve renders HTML, never PDFs, so the -slim image variant is enough — it saves about 700 MB
per pod by leaving Chromium out.
Define the LiveReportArtefact
Section titled “Define the LiveReportArtefact”bino serve serves exactly one LiveReportArtefact, named with --live. It maps URL routes onto
existing ReportArtefacts and declares the query parameters each route accepts:
# manifests/live.yaml
apiVersion: bino.bi/v1alpha1
kind: LiveReportArtefact
metadata:
name: sales-dashboard
spec:
title: "Sales Dashboard"
description: "Interactive sales reporting"
routes:
"/":
artefact: overview-report
"/region":
artefact: region-report
title: "Regional Sales"
queryParams:
- name: REGION
type: select
default: "all"
description: "Filter by region"
options:
items:
- value: "all"
label: "All Regions"
- value: "EU"
label: "Europe"
- value: "US"
label: "United States"
- name: YEAR
type: number
default: "2026"
description: "Reporting year"
options:
min: 2000
max: 2030A root route "/" is required, and every artefact must name a ReportArtefact that exists in the
bundle. See the LiveReportArtefact reference for all parameter types.
Run it with docker run
Section titled “Run it with docker run”docker run --rm \
-p 8080:8080 \
-v "$PWD:/work" \
-e DB_HOST \
-e POSTGRES_PASSWORD \
ghcr.io/bino-bi/bino-cli:latest-slim \
serve --live sales-dashboard --addr 0.0.0.0:8080Confirm it is up:
curl -fsS http://localhost:8080/healthzThe bundle must stay mounted and readable for the whole life of the container. serve reloads
manifests from disk whenever a request misses the render cache — this is not a build-once,
ship-static model.
Move flags into bino.toml
Section titled “Move flags into bino.toml”Everything except --addr can move into the project's bino.toml, which keeps the container command
short and puts the configuration under version control:
[serve.args]
live = "sales-dashboard"
log-sql = false
[serve.env]
BNR_MAX_QUERY_ROWS = "250000"
BNR_MAX_QUERY_DURATION_MS = "60000"The container command then collapses to serve --addr 0.0.0.0:8080. Real environment variables win
over [serve.env], so a Kubernetes Secret still overrides the file.
Docker Compose
Section titled “Docker Compose”# docker-compose.yml
services:
dashboard:
image: ghcr.io/bino-bi/bino-cli:latest-slim
command: ["serve", "--live", "sales-dashboard", "--addr", "0.0.0.0:8080"]
volumes:
- ./:/work
ports:
- "8080:8080"
environment:
DB_HOST: db
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}"
BNR_MAX_QUERY_ROWS: "250000"
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_DB: analytics
POSTGRES_USER: reporting
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Your ConnectionSecret reaches the database at the service name db, not localhost:
apiVersion: bino.bi/v1alpha1
kind: ConnectionSecret
metadata:
name: postgresCredentials
spec:
type: postgres
postgres:
host: "${DB_HOST:localhost}"
passwordFromEnv: POSTGRES_PASSWORDKubernetes
Section titled “Kubernetes”Bake the bundle into an image
Section titled “Bake the bundle into an image”Because serve reads manifests from disk at request time, the bundle has to be present in every pod
for the pod's whole life. The cleanest way to do that is a two-line derived image — immutable,
identical across replicas, and no ReadWriteMany volume:
# Dockerfile
FROM ghcr.io/bino-bi/bino-cli:v0.90.0-slim
COPY --chown=1000:0 . /workdocker build -t registry.example.com/reports/sales-dashboard:2026-07-28 .
docker push registry.example.com/reports/sales-dashboard:2026-07-28A ConfigMap works for a bundle of a handful of small manifests, and a git-sync sidecar works if you want the bundle to track a branch — but both reintroduce the question of what a given pod is serving at any moment. A tagged image answers it.
Deployment
Section titled “Deployment”apiVersion: apps/v1
kind: Deployment
metadata:
name: sales-dashboard
labels:
app.kubernetes.io/name: sales-dashboard
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: sales-dashboard
template:
metadata:
labels:
app.kubernetes.io/name: sales-dashboard
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 0
seccompProfile:
type: RuntimeDefault
containers:
- name: bino
image: registry.example.com/reports/sales-dashboard:2026-07-28
args: ["serve", "--live", "sales-dashboard", "--addr", "0.0.0.0:8080"]
ports:
- name: http
containerPort: 8080
env:
- name: DB_HOST
value: postgres.data.svc.cluster.local
- name: BNR_MAX_QUERY_ROWS
value: "250000"
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: sales-dashboard-db
key: password
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
failureThreshold: 60
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
memory: "2Gi"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}Service
Section titled “Service”apiVersion: v1
kind: Service
metadata:
name: sales-dashboard
spec:
selector:
app.kubernetes.io/name: sales-dashboard
ports:
- name: http
port: 80
targetPort: httpCreate the secret out of band:
kubectl create secret generic sales-dashboard-db \
--from-literal=password="$POSTGRES_PASSWORD"Environment variables are read at request time, not only at startup — ${VAR} substitution and the
*FromEnv fields of a ConnectionSecret are resolved per render. Everything the report needs to
reach its data must be in the container environment, not just in the build environment.
About the probes
Section titled “About the probes”GET /healthz returns 200 with the body ok. It is unauthenticated and it does not render a
report — it tells you the process is up and the HTTP mux is serving, nothing more. That makes it the
right target for all three probes and the wrong tool for "is my data pipeline healthy".
startupProbe— be generous. On boot,serverenders every route once to enumerate assets. A bundle with a dozen routes over a slow warehouse can take minutes. The example allows60 × 5s = 5 minutes; measure your own boot and add headroom. Liveness and readiness do not run until the startup probe passes, so a generous budget costs nothing at steady state.livenessProbe— never point it at a report route. A slow render would trip the probe and restart the pod mid-request, making the next render slower still.- Data-freshness checks belong outside. If you need to know that a route actually renders, use an external synthetic check against a route whose parameters all have defaults.
Scaling
Section titled “Scaling”-
Concurrency equals replica count. Two replicas serve two simultaneous renders.
-
Each replica keeps its own render cache — an LRU of 100 entries keyed by the query parameters — so the hit rate falls as you add replicas. If your users hit the same parameter combinations repeatedly, sticky routing recovers most of it:
spec: sessionAffinity: ClientIP -
A
HorizontalPodAutoscaleron CPU works poorly here, because a pod blocked on a query looks idle. Scale on request concurrency, or just size for peak.
TLS and access control
Section titled “TLS and access control”Terminate TLS and enforce authentication in a reverse proxy in front of the Service:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sales-dashboard
annotations:
cert-manager.io/cluster-issuer: letsencrypt
nginx.ingress.kubernetes.io/auth-url: "https://auth.example.com/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://auth.example.com/oauth2/start?rd=$escaped_request_uri"
spec:
ingressClassName: nginx
tls:
- hosts: ["reports.example.com"]
secretName: sales-dashboard-tls
rules:
- host: reports.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sales-dashboard
port:
name: httpOn a single host, Caddy, nginx, or Traefik in front of the Compose service does the same job. Add a
NetworkPolicy so only the ingress controller can reach port 8080 directly.
Refreshing the data
Section titled “Refreshing the data”A running serve process reloads manifests on a cache miss but answers repeat URLs from its render
cache, so fresh data appears when a new parameter combination is requested or when the pod restarts.
For a predictable refresh, restart on a schedule or roll a new bundle image:
kubectl rollout restart deployment/sales-dashboardWire the image build into the pipeline from Recipe: CI/CD pipeline, and use
a pre-serve hook if a pod needs to warm data before it takes traffic:
[serve.hooks]
pre-serve = ["./scripts/warmup.sh"]Hooks run with BINO_MODE=serve, BINO_LISTEN_ADDR, and BINO_LIVE_ARTEFACT set — see Hooks.
Installing the dashboard as an app
Section titled “Installing the dashboard as an app”If the LiveReportArtefact defines spec.pwa, the deployed dashboard is installable as a Progressive Web App straight from the browser. Installation requires HTTPS, which the reverse proxy above already provides — see bino serve: Progressive Web App for what is served and how offline behavior works.
Two deployment properties matter:
- Every PWA URL is relative. The manifest, the service worker, the icon references, and the tags injected into the HTML all use relative URLs, resolved against the page URL. The same bundle is therefore installable at the domain root (
https://reports.example.com/) or under a path prefix, with no configuration difference. bino cloud relies on this: it hosts live artefacts under/l/<slug>/behind a reverse proxy and injects a<base href="/l/<slug>/">into the HTML, which makes the artefact installable from its/l/<slug>/URL unchanged. Installed apps stay signed in via device sessions managed by the cloud. - Proxies must preserve the path prefix. If your own proxy serves the dashboard under a prefix (say
/dashboard/), it must forward every path under that prefix to the same serve process and must not redirect between prefixed and unprefixed URLs. The installed app's scope is the prefix it was installed from; a proxy that drops or rewrites the browser-visible prefix breaks manifest and service-worker resolution.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
| Connection refused from outside the container | serve bound 127.0.0.1. | Pass --addr 0.0.0.0:8080; it cannot come from bino.toml. |
--live flag is required | No artefact named. | Pass --live <name> or set live under [serve.args]. |
| Pod restarts in a loop before serving traffic | startupProbe budget shorter than the boot pre-render. | Raise failureThreshold. |
| A route shows a form instead of the report | A required query parameter has no value. | Give it a default or optional: true, or pass it in the URL. |
| Renders queue up under load | Renders are serialised per process. | Add replicas. |
| OOMKilled | A large result set exceeded the memory limit. | Raise the limit and lower BNR_MAX_QUERY_ROWS. |
| Data never changes | The render cache answered the repeat request. | Restart the pod, or roll a new image tag. |
See also: bino serve for the full flag reference, Running bino in Docker for the image itself, and LiveReportArtefact for route and parameter options.