Metabase¶
Open-source business-intelligence / dashboarding app in the database namespace, backed by the
shared postgres18 CloudNativePG cluster.
Purpose¶
- Provide self-service analytics over the cluster's Postgres datasets (security findings, app DBs, pgAdmin-managed databases) without requiring everyone to write SQL.
- Stateless pod: all Metabase application state (questions, dashboards, saved DB connections) lives in
a dedicated database on
postgres18, so no PVC is needed./pluginsand/tmpareemptyDir.
Design decisions¶
- CloudNativePG, not embedded H2. Metabase's own docs warn against H2 in production (corruption risk, no concurrent access). Reusing the hardened, replicated, backed-up cluster costs one extra database; app data is captured by the existing CNPG backup pipeline (Barman + scheduled remote).
- bjw-s
app-templatechart, like every other app in the namespace: one Deployment + Service + route +postgres-initinitContainer fits it cleanly. The dedicatedmetabaseDB and role are bootstrapped idempotently by theghcr.io/home-operations/postgres-initinitContainer on first run. - Internal-only ingress. Inline
route:onenvoy-internalformetabase.${SECRET_DOMAIN}. Theenvoy-internalattachment is what keeps it internal-only, not the domain. Cloudflare-fronting can be added later by switching theparentReftoenvoy-external. - Native Prometheus exporter, not a JMX sidecar. Metabase 0.49+ ships a built-in
/metricsendpoint viaMB_PROMETHEUS_SERVER_PORT(port 9191 here), scraped by aPodMonitor; a community Grafana dashboard is imported via aGrafanaDashboardCR. - Deferred (YAGNI): SMTP, SSO/LDAP/OIDC (paid edition only on OSS; first run creates a local
admin),
/pluginsPVC for third-party JDBC drivers, and pre-seeded source DBs/dashboards. All are configurable later through the UI without redeploying.
Deploy gotchas¶
- JVM TLS rejects the CNPG server cert: use
sslmode=disable. Java'sX509Factoryrejects CloudNativePG's empty-issuer-DN server certificate, so a JVM app cannot doverify-fullTLS to thepostgres18cluster. Point Metabase at the in-cluster service with TLS verification off (the pod →*-rw.svc.cluster.localnetwork path is already trusted):
MB_DB_CONNECTION_URI=jdbc:postgresql://postgres18-rw.database.svc.cluster.local:5432/metabase?sslmode=disable
This applies to any JVM/JDBC app talking to CNPG, not just Metabase.
-
--add-opensis required for the metrics collector. On JDK 17+ the native Prometheus collector needsJAVA_OPTS: "--add-opens java.base/java.nio=ALL-UNNAMED", or/metricsfails to start. Future versions may need more--add-opensflags; the pod logs the JVM error on start, so append as needed. -
Slow first-run cold start. The first boot runs Liquibase migrations against an empty app DB (~60-90s). Size the startup probe for it (
failureThreshold: 60,periodSeconds: 5against/api/health); a too-tight threshold CrashLoops the pod before it finishes migrating. -
First-run migration can spike memory. Set the memory limit well above Metabase's documented 1Gi floor (3Gi limit / 1.5Gi request here) so the migration doesn't OOM.
-
Secret key names are Metabase's, not the values defaults. The ExternalSecret combines the per-app
metabase1Password item with thecloudnative-pgitem, and must materialise the exact env names the container and initContainer read:MB_DB_*/MB_ENCRYPTION_SECRET_KEYfor the app,INIT_POSTGRES_*(includingINIT_POSTGRES_SUPER_*) for the init container. Generate the encryption key and DB password locally and create the 1Password item viaop item create, never by hand.
Operational notes¶
- Never lose or naively rotate
MB_ENCRYPTION_SECRET_KEY. It encrypts the source-DB credentials stored inside Metabase's app DB; losing it means Metabase can no longer decrypt saved connections. The key's canonical home is 1Password. To rotate, run Metabase'srotate-encryption-keyadmin command first, then update the secret. - Recovery is via CNPG, not the app. Since all state is in the
metabasedatabase onpostgres18, a corrupt or failed schema migration is recovered by point-in-time restore of that DB from the CNPG backups. There is no app-side backup. - If
postgres18is down at startup, thepostgres-initinitContainer exits non-zero and the pod restarts with backoff; once Postgres returns, the next attempt succeeds with no manual intervention. - First-run setup is manual. Browse to the internal hostname, complete the wizard to create the local admin, then add source databases through the UI.
- Health checks: liveness/readiness hit
GET /api/health(returns{"status":"ok"}); Gatus monitors it automatically (the gatus-sidecar chart auto-discovers the HTTPRoute). Confirm metrics with a port-forward to 9191 and look forjvm_memory_used_bytes/metabase_*series.