Metabase is the BI tool teams actually adopt, because non-engineers can use it without a SQL course. Deploying it is easy to get 90% right — the official Docker image boots on the first try — and that last 10% is where people lose their dashboards. The default configuration stores everything in an embedded H2 database file, which means one container redeploy can wipe every question, dashboard, and user your team created.
Here's the production setup: Postgres as the application database, an encryption key that survives restarts, enough memory for the JVM, and deploys on git push.
One distinction that prevents most confusion
Metabase involves two kinds of databases, and mixing them up causes real damage:
- The application database: where Metabase stores *its own* state — users, dashboards, questions, saved connections. This is what H2 holds by default and what you must move to Postgres.
- The data warehouses: the databases you *connect* Metabase to so people can query them. You add these later, in the admin UI.
Everything in this post about migrations and DATABASE_URL refers to the application database.
Why H2 has to go
The embedded H2 database is a single file inside the container. On any platform with ephemeral container filesystems — which is nearly every modern platform — that file is deleted on redeploy. Metabase's own docs are blunt that H2 is for trying the software, not running it.
Switching is one environment variable family. Metabase accepts either discrete settings:
MB_DB_TYPE=postgres
MB_DB_HOST=your-db-host
MB_DB_PORT=5432
MB_DB_DBNAME=metabase
MB_DB_USER=metabase
MB_DB_PASS=...…or a single connection URI:
MB_DB_CONNECTION_URI=postgresql://user:password@host:5432/metabaseIf your password contains URL-special characters (@, #, /…), use the query-parameter form instead of inline credentials:
MB_DB_CONNECTION_URI="postgresql://host:5432/metabase?user=metabase&password=..."Bridging DATABASE_URL to Metabase
Managed platforms typically inject a single DATABASE_URL. Metabase doesn't read that variable name — but its connection-URI format is the same shape, so the bridge is a two-line entrypoint wrapper:
#!/bin/sh
# run-wrapper.sh
if [ -n "$DATABASE_URL" ] && [ -z "$MB_DB_CONNECTION_URI" ]; then
export MB_DB_CONNECTION_URI="$DATABASE_URL"
fi
exec /app/run_metabase.shAnd the Dockerfile:
FROM metabase/metabase:v0.50.x # pin a real version, not :latest
COPY run-wrapper.sh /run-wrapper.sh
RUN chmod +x /run-wrapper.sh
EXPOSE 3000
ENTRYPOINT ["/run-wrapper.sh"]Metabase listens on port 3000 (MB_JETTY_PORT to change it). Pin the image version: Metabase releases often, and each upgrade runs schema migrations against your application database — you want upgrades to be a decision, not a side effect of a rebuild.
The environment variables that matter
# Encrypts data-source credentials stored in the app DB.
# Minimum 16 characters. Set BEFORE connecting your first warehouse.
MB_ENCRYPTION_SECRET_KEY=<openssl rand -base64 32>
# Used for links in emails, embeds, and auth redirects
MB_SITE_URL=https://metabase.yourdomain.com
# JVM heap — Metabase is a Java app and wants real memory
JAVA_OPTS=-Xmx1gThe encryption key deserves the same paranoia as n8n's or Rails' secrets: set it once, store it somewhere safe, never rotate it casually. Without it, warehouse credentials sit in the application database unencrypted; with it, losing the key means re-entering every connection.
On memory: Metabase runs comfortably for small teams around 1–2 GB of RAM, but it is not a 256 MB app. Budget the container accordingly and set -Xmx below the container limit so the JVM gets OOM-killed by neither itself nor the kernel.
Migrations: automatic, with one sharp edge
Metabase runs its schema migrations (via Liquibase) automatically at startup — first boot creates the whole schema, upgrades apply diffs. Two operational notes:
- 1Run one instance during upgrades. Liquibase takes a lock in the database; two replicas booting a new version simultaneously will fight over it.
- 2If a container is killed mid-migration, the lock can be left stuck and subsequent boots will wait on it. The fix is Metabase's built-in command:
java -jar metabase.jar migrate release-locksYou'll likely never need it — but knowing it exists turns a scary incident into a one-liner.
Deploying on PandaStack
The full path, start to finish:
- 1Provision managed PostgreSQL (14.x or 16.x) from the PandaStack dashboard. It's KubeBlocks-orchestrated, with daily scheduled backups — retention is 7, 15, or 30 days depending on plan, which matters here because the application database *is* your BI team's work product.
- 2Push a repo containing the Dockerfile and
run-wrapper.shabove, and connect it as a container app. The image builds with rootless BuildKit in an ephemeral build pod; build logs stream live so you can watch the layers pull. - 3Attach the database to the app.
DATABASE_URLis injected automatically, the wrapper maps it toMB_DB_CONNECTION_URI, and Metabase boots straight into Postgres — no H2, no credential copy-paste. - 4Set
MB_ENCRYPTION_SECRET_KEY,MB_SITE_URL, andJAVA_OPTSin the app's environment variables. - 5Add your custom domain — SSL is automatic — and set
MB_SITE_URLto match.
First boot takes noticeably longer than a redeploy because Metabase is creating a few hundred tables of schema. Watch the app logs; when you see the Jetty startup line, hit the health check:
curl https://metabase.yourdomain.com/api/health
# {"status":"ok"}Then complete the setup wizard and connect your first data warehouse. If that warehouse is your production app's database, create a dedicated read-only Postgres user for Metabase rather than handing it your app's credentials — analysts writing exploratory queries against a table with write access is a category of incident you can simply opt out of.
Sizing note for the free tier
Two honest caveats. First, PandaStack's free tier scales idle apps to zero — and a JVM app like Metabase has the slowest cold start of anything you'll deploy, so your first dashboard load after a quiet night will crawl. Second, the free compute tier's 512 MB of RAM is below Metabase's comfort zone. For a team actually using it daily, put it on a paid always-on tier — memory-optimized tiers exist for exactly this shape of app — and it stays snappy.
Checklist before you call it done
- Application database is Postgres — confirm with
psql "$DATABASE_URL" -c "\dt" | grep core_user MB_ENCRYPTION_SECRET_KEYset before the first warehouse connection- Image version pinned; upgrades are deliberate
- Warehouse connections use read-only users
/api/healthreturns ok after a redeploy — and your dashboards are still there
That last check is the whole point. A Metabase deploy is correct when a redeploy is boring. If you'd rather the Postgres provisioning and wiring were the boring part too, give it a run on https://pandastack.io.