Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
122 NOTES IN PRINT
FOLIO CXXII 2026-07-11 · 9 MIN · LONG-FORM

Self-Hosted BI with Apache Superset

Dashboards and SQL exploration over the lakehouse and Postgres, a Tableau replacement on the platform's SSO

Diagram · folio cxxii
flowchart TB
  USER["users"] -->|Keycloak SSO| SUP["Apache Superset"]
  SUP -->|SQL| TR["Trino lakehouse"]
  SUP -->|SQL| PG["Postgres"]
  SUP --> META[("metadata in Postgres")]

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.

← back to all notes