A data platform is not finished until people can see the data. Everything built so far stores, moves, and queries it; the last piece is the front end where someone who is not writing YAML builds a chart and ships a dashboard. The SaaS answer is Tableau, Looker, or Power BI, per seat, with your data pulled into their cloud. The open answer is Apache Superset, and it has grown into a Tableau replacement: it queries the lakehouse and Postgres directly, and logs everyone in through the Keycloak single sign-on from the last posts.
This series rebuilds my 2020 Apress book, Advanced Platform Development with Kubernetes, for 2026. The approach behind it comes from building and running data platforms in production for more than twenty years.
§Superset in 2026
Superset was promising in 2020 and rough in places. The rough edges are gone. It does the things a BI tool has to do, a visual chart builder over almost any SQL source, dashboards with cross-filtering, a SQL editor for ad-hoc work, scheduled reports and alerts, and row-level security, and it does them well enough to stand in for the commercial seats most teams pay for. Pointed at Trino, it queries your Iceberg lakehouse as if it were a warehouse; pointed at Postgres, it reads your operational data live.
The economics are the reason this matters. Tableau, Looker, and Power BI charge per seat, and the seat is the unit that grows exactly as you succeed at getting people to use the data. A platform that makes data accessible is one whose BI bill rises with adoption, which is a perverse incentive to put in front of a team. Worse, most of these tools want your data pulled into their model or their cloud to perform, so the analytics layer becomes another copy of your data living somewhere you do not control. Superset avoids both: no per-seat license, and it queries your data in place, where it already lives on the platform, rather than extracting it first.
§A Database and Secrets
Superset keeps its own metadata, dashboards, charts, and saved queries, in a database. Ask CloudNativePG for a superset database, the same way Keycloak got one.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: superset
namespace: data
spec:
cluster:
name: platform-pg
name: superset
owner: app
Superset needs a secret key for signing, and the OIDC client secret Keycloak generated for a superset client (create that client in the platform realm exactly as you did for NiFi, with redirect URI https://bi.apk8s.dev/*).
kubectl create namespace bi
kubectl -n bi create secret generic superset-secrets \
--from-literal=secret-key="$(openssl rand -base64 42)" \
--from-literal=oidc-client-secret='the-superset-client-secret-from-keycloak'
§Configure Superset
Superset is configured in Python. A ConfigMap holds superset_config.py: the metadata database, OAuth against Keycloak, and a small security manager that reads the Keycloak userinfo to populate accounts on first login.
apiVersion: v1
kind: ConfigMap
metadata:
name: superset-config
namespace: bi
data:
superset_config.py: |
import os
from flask_appbuilder.security.manager import AUTH_OAUTH
from superset.security import SupersetSecurityManager
SECRET_KEY = os.environ["SUPERSET_SECRET_KEY"]
SQLALCHEMY_DATABASE_URI = (
f"postgresql://app:{os.environ['DB_PASSWORD']}@platform-pg-rw.data:5432/superset"
)
AUTH_TYPE = AUTH_OAUTH
AUTH_USER_REGISTRATION = True
AUTH_USER_REGISTRATION_ROLE = "Gamma"
OAUTH_PROVIDERS = [
{
"name": "keycloak",
"icon": "fa-key",
"token_key": "access_token",
"remote_app": {
"client_id": "superset",
"client_secret": os.environ["OIDC_CLIENT_SECRET"],
"server_metadata_url":
"https://auth.apk8s.dev/realms/platform/.well-known/openid-configuration",
"client_kwargs": {"scope": "openid email profile"},
},
}
]
class KeycloakSecurityManager(SupersetSecurityManager):
def oauth_user_info(self, provider, response=None):
me = self.appbuilder.sm.oauth_remotes[provider].get(
"https://auth.apk8s.dev/realms/platform/protocol/openid-connect/userinfo"
).json()
return {
"username": me.get("preferred_username"),
"email": me.get("email"),
"first_name": me.get("given_name"),
"last_name": me.get("family_name"),
}
CUSTOM_SECURITY_MANAGER = KeycloakSecurityManager
§Initialize and Run
Before serving, Superset has to migrate its metadata schema and seed its roles. A Job does it once, with the same environment the web server uses; the secret key, the Postgres password, and the OIDC secret all come from secrets.
apiVersion: batch/v1
kind: Job
metadata:
name: superset-init
namespace: bi
spec:
template:
spec:
restartPolicy: Never
containers:
- name: init
image: apache/superset:4.1.1 # pin a current version
command: ["/bin/sh", "-c", "superset db upgrade && superset init"]
env:
- name: SUPERSET_SECRET_KEY
valueFrom: { secretKeyRef: { name: superset-secrets, key: secret-key } }
- name: OIDC_CLIENT_SECRET
valueFrom: { secretKeyRef: { name: superset-secrets, key: oidc-client-secret } }
- name: DB_PASSWORD
valueFrom: { secretKeyRef: { name: platform-pg-app, key: password } }
- { name: SUPERSET_CONFIG_PATH, value: /app/pythonpath/superset_config.py }
volumeMounts:
- { name: config, mountPath: /app/pythonpath/superset_config.py, subPath: superset_config.py }
volumes:
- name: config
configMap: { name: superset-config }
The web server runs the same image with gunicorn and the same environment and config.
apiVersion: apps/v1
kind: Deployment
metadata:
name: superset
namespace: bi
spec:
replicas: 1
selector:
matchLabels:
app: superset
template:
metadata:
labels:
app: superset
spec:
containers:
- name: superset
image: apache/superset:4.1.1
command:
- /bin/sh
- -c
- gunicorn --bind 0.0.0.0:8088 --workers 4 --timeout 120 "superset.app:create_app()"
ports:
- { name: http, containerPort: 8088 }
env:
- name: SUPERSET_SECRET_KEY
valueFrom: { secretKeyRef: { name: superset-secrets, key: secret-key } }
- name: OIDC_CLIENT_SECRET
valueFrom: { secretKeyRef: { name: superset-secrets, key: oidc-client-secret } }
- name: DB_PASSWORD
valueFrom: { secretKeyRef: { name: platform-pg-app, key: password } }
- { name: SUPERSET_CONFIG_PATH, value: /app/pythonpath/superset_config.py }
volumeMounts:
- { name: config, mountPath: /app/pythonpath/superset_config.py, subPath: superset_config.py }
volumes:
- name: config
configMap: { name: superset-config }
---
apiVersion: v1
kind: Service
metadata:
name: superset
namespace: bi
spec:
selector:
app: superset
ports:
- { name: http, port: 8088, targetPort: 8088 }
Expose it through the gateway with an HTTPRoute at bi.apk8s.dev.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: superset
namespace: bi
spec:
parentRefs:
- name: platform-gateway
namespace: gateway
hostnames:
- "bi.apk8s.dev"
rules:
- backendRefs:
- name: superset
port: 8088
Apply it all and wait for the init job before the web server is useful.
kubectl apply -f superset-config.yaml -f superset-init.yaml -f superset.yaml -f superset-route.yaml
kubectl -n bi wait --for=condition=complete job/superset-init
Open https://bi.apk8s.dev, sign in through Keycloak, and you land in Superset. The first user registers as Gamma; promote yourself to Admin once (or map a Keycloak role to Admin with AUTH_ROLES_MAPPING).
§Connect the Platform’s Data
Superset reaches a source through a SQLAlchemy URL added under database connections. Two matter here. The lakehouse, through Trino:
trino://[email protected]:8080/iceberg
And Postgres directly, for live operational data:
postgresql://app:<password>@platform-pg-rw.data:5432/app
With those connected, the iceberg.analytics.events table from the lakehouse post and any Postgres table show up as datasets. Build a chart on one, drop it on a dashboard, add a filter, and share the URL; everyone who opens it authenticates through the same Keycloak. SQL Lab gives analysts a query editor against either source without leaving the browser.
§Operating the BI Layer
A single web pod is plenty to start. Running BI for a team adds a few concerns, and Superset handles each.
The async stack. Heavy use wants caching and background work: cached query results so a popular dashboard does not re-run the same Trino query for every viewer, and asynchronous execution for long SQL Lab queries, scheduled reports, and alerts. Both want a fast in-memory store and a task queue, and the natural choice would once have been Redis. Redis relicensed to the SSPL in 2024, the same move Elasticsearch and MinIO made, so the platform uses Valkey, the Linux Foundation’s drop-in fork that stayed open. Superset uses Valkey for its cache and as the Celery broker, with Celery worker and beat deployments running the background jobs. It is the same rug-pull-avoidance reasoning as everywhere else in this series, applied to one more dependency. The official Superset Helm chart wires the full set, web, worker, beat, and Valkey, which is the one place I would reach for Helm here, because the async topology has enough moving parts that the chart is worth using as the assembly.
Governance through roles. Who sees what matters for a BI tool. Superset maps Keycloak roles to its own with AUTH_ROLES_MAPPING and AUTH_ROLES_SYNC_AT_LOGIN, so a user’s group in Keycloak decides whether they land as an Admin, a dashboard author, or a read-only viewer, enforced from the single identity source rather than managed twice. Row-level security goes further, filtering the rows a role can see within a dataset, so the same dashboard shows each team only its own data. Central identity from the Keycloak post pays off here: access is governed in one place and inherited by the front end.
Dashboards as code. Charts, datasets, and dashboards export to YAML and import the same way, so a dashboard built in development is promoted to production as a reviewed change rather than rebuilt by hand. And because Superset’s own state lives in the superset Postgres database, the platform’s database backups already protect every dashboard and saved query; there is nothing extra to back up.
§When Something Is Wrong
Login through Keycloak fails or loops. The usual OIDC suspects: the superset client’s redirect URI must match https://bi.apk8s.dev exactly, and the server_metadata_url must be reachable from the pod. A loop usually means the security manager is not reading userinfo correctly, so check the oauth_user_info claims match what the realm sends.
The Trino connection fails to add. The image needs the Trino SQLAlchemy driver. Recent Superset images bundle the common drivers, but if trino:// is rejected, add the trino Python package to the image or use the Helm chart’s additional-packages hook.
Sessions drop or encrypted values break after a restart. The SECRET_KEY changed. It signs sessions and encrypts stored credentials, so it must be stable across restarts; keep it in the persisted secret and do not regenerate it.
Scheduled reports and async queries never run. There is no Celery worker, or no broker. Asynchronous work needs the Valkey broker and at least one worker and beat pod; the synchronous web server alone cannot run them.
§The Platform Is Real
Step back and look at what is running: a cluster you own, replicated storage, a gateway with automatic TLS, Postgres, Kafka, OpenSearch, an object store, a Trino and Iceberg lakehouse, NiFi moving data through all of it, single sign-on across the lot, and now a BI front end your whole team can use. That is a serious data platform, the equal of an expensive stack of managed services, assembled from liberally licensed open source on infrastructure you control.
What comes next is where this platform earns its keep in 2026: making everything you have built legible to AI. The leverage now is in giving frontier models access to your platform and the context to use it well, more than in building your own agents or running models in-cluster, which is fun at small scale. Context comes first. Before a model can answer a question about your data, it has to know what the data means, so I start there, with a metadata catalog: DataHub.