Back to Blog
Tutorial6 min read2026-07-12

How to Deploy a Metabase Instance with PostgreSQL

Deploy Metabase for real: kill the H2 default, wire the app database to managed Postgres, set the encryption key, and ship it with a Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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/metabase

If 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.sh

And 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=-Xmx1g

The 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:

  1. 1Run one instance during upgrades. Liquibase takes a lock in the database; two replicas booting a new version simultaneously will fight over it.
  2. 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-locks

You'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:

  1. 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.
  2. 2Push a repo containing the Dockerfile and run-wrapper.sh above, 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.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically, the wrapper maps it to MB_DB_CONNECTION_URI, and Metabase boots straight into Postgres — no H2, no credential copy-paste.
  4. 4Set MB_ENCRYPTION_SECRET_KEY, MB_SITE_URL, and JAVA_OPTS in the app's environment variables.
  5. 5Add your custom domain — SSL is automatic — and set MB_SITE_URL to 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_KEY set before the first warehouse connection
  • Image version pinned; upgrades are deliberate
  • Warehouse connections use read-only users
  • /api/health returns 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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also