Two things break self-hosted Metabase, and almost every tutorial leaves both out: it defaults to an embedded H2 database that you must not keep, and it needs more memory than the box people put it on.
Metabase is a genuinely good open-source BI tool and it is easy to start. Pull the image, map a port, open it in a browser, done. The trouble arrives weeks later when the container gets recreated and every dashboard is gone, or immediately, in the form of a white screen that the container logs explain if you know what to look for. Both have the same root cause: the defaults are demo defaults.
Table of contents
- What Metabase actually needs
- The H2 default is the trap
- A compose file with Postgres behind it
- Memory: why a small box shows a white screen
- Reverse proxy, TLS, and the health check
- Backups and upgrades
- How this fits the rest of the stack
- FAQ
What Metabase actually needs
Before the compose file, the shopping list. Metabase is a Java application, which shapes all of its requirements.
- A JVM with real memory. Metabase runs in a JVM that wants around 1GB of heap on its own, plus the container overhead around it. A 1GB instance is below the floor, not close to it.
- A separate application database. This is where your questions, dashboards, users, and permissions live. It is not the database you are analysing.
- Network access to the data sources you want to query. Metabase connects out to them, so they need to be reachable from wherever it runs.
- A reverse proxy with TLS. Metabase can serve HTTPS itself but it is simpler and more maintainable to terminate TLS in front of it.
- Somewhere durable for the application database. A named volume at minimum, a managed instance ideally.
The last two items on that list are where self-hosted deployments quietly go wrong, and the application database is the one that costs people their work.
The H2 default is the trap
With no configuration, Metabase stores everything in an embedded H2 file inside the container. It works immediately, which is exactly the problem, because everything you build lands in a file that disappears with the container.
Metabase’s own documentation is direct that H2 is for evaluation only. Beyond the durability issue, the H2 file can corrupt under load or an unclean shutdown, and there is no meaningful repair path when it does. If your dashboards matter at all, this needs changing before you build the first one, not after.
The fix is four environment variables pointing Metabase at a Postgres instance for its own bookkeeping:
MB_DB_TYPE=postgres
MB_DB_DBNAME=metabase
MB_DB_PORT=5432
MB_DB_USER=metabase
MB_DB_PASS=change-me
MB_DB_HOST=db
Set these before the first launch and Metabase initialises its schema in Postgres directly. If you have already been running on H2 and want to keep your work, Metabase ships a migration command that dumps H2 into a target database. It is worth doing the day you realise, because it gets harder as the H2 file grows.
A compose file with Postgres behind it
This is the whole deployment for a single box. Two services, one named volume, no surprises.
services:
metabase:
image: metabase/metabase:latest
ports:
- "127.0.0.1:3000:3000"
environment:
MB_DB_TYPE: postgres
MB_DB_DBNAME: metabase
MB_DB_PORT: 5432
MB_DB_USER: metabase
MB_DB_PASS: change-me
MB_DB_HOST: db
JAVA_OPTS: -Xmx1g
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_DB: metabase
POSTGRES_USER: metabase
POSTGRES_PASSWORD: change-me
volumes:
- mbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U metabase"]
interval: 10s
retries: 5
restart: unless-stopped
volumes:
mbdata:
Two details worth noting. The port binds to 127.0.0.1 rather than all interfaces, so Metabase is reachable only through the reverse proxy and not directly on port 3000 from the internet. And the health check on Postgres with depends_on prevents Metabase starting against a database that is not accepting connections yet, which otherwise produces a confusing startup failure on every reboot.
Memory: why a small box shows a white screen
The white screen after following a tutorial is the most reported Metabase self-hosting problem, and it is nearly always the JVM being killed by the kernel out-of-memory reaper. The container appears to be running, the port answers, and the page loads to nothing.
Confirm it in one command:
docker logs metabase 2>&1 | tail -50
dmesg -T | grep -i 'killed process'
If you see a Java heap space error, or the kernel reporting a killed process, the box is too small. Metabase wants roughly 2GB of RAM available to be comfortable, and more if several people run large queries at once. Running it alongside Postgres on the same 1GB instance puts both under pressure.
Setting JAVA_OPTS: -Xmx1g as in the compose file above is worth doing regardless. Without an explicit maximum heap the JVM sizes itself from what it can see, which inside a container is sometimes the host’s total memory rather than the container’s limit, and it will happily try to use memory it does not have.
If the app is up but every query is slow rather than dead, that is a different problem: it is usually the analysed database rather than Metabase, and the query log on that side is where to look.
Reverse proxy, TLS, and the health check
With Metabase bound to localhost, nginx does the public-facing work.
server {
listen 443 ssl;
server_name bi.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}
The generous proxy_read_timeout is deliberate. Analytical queries routinely run longer than a default 60-second timeout, and the symptom of getting this wrong is a gateway error on exactly the queries people care about most.
Metabase exposes /api/health, which returns a small JSON payload once the application has finished starting. Point your platform’s health check at that rather than the root path. Startup takes a while on first run because of schema migrations, so allow a generous initial delay before the check starts failing things.
Backups and upgrades
Once the application database is Postgres, both of these become ordinary.
- Backups are a
pg_dumpof the Metabase application database on a schedule, stored somewhere that is not the same box. That file contains every dashboard, question, and permission setting. - Upgrades are pulling a newer image tag and recreating the container. Metabase runs its schema migrations on startup, which is why a backup immediately before an upgrade is not optional.
- Pin the version tag. Running
latestmeans an unattended pull can move you across a major version with a one-way schema migration behind it. - Test the restore. A backup you have never restored is a hypothesis.
This is also the point where a managed Postgres instance starts paying for itself. Scheduled backups, connection limits, and private networking are exactly the operational work you would otherwise be doing by hand around a container, and the analytics tool is not usually where a team wants to spend its database administration time.
How this fits the rest of the stack
Self-hosted Metabase is really two workloads: a memory-hungry Java service and a small but genuinely important Postgres instance holding everything anyone has built. Sizing them separately is what stops the white screen, and it is also what makes the cost legible instead of a single guess at a box size. The RunxBuild hosting calculator prices a Docker service and a managed Postgres as two line items, which maps directly onto the compose file above. On the platform, Metabase deploys as a Docker service with environment variables, runtime logs, and a health check, with the application database as a managed Postgres instance that gets backups and private networking without you scripting them.
Useful related references:
- RunxBuild vs DigitalOcean: Cloud Hosting Compared
- DigitalOcean Spaces: What S3-Compatible Object Storage Is Actually For
- DigitalOcean vs AWS: Where the Price Gap Actually Comes From
- Databases on RunxBuild
FAQ
Why does Metabase show a white screen after installation?
Almost always the JVM being killed for running out of memory. The container stays up and the port answers, but the application is dead. Check the container logs for a Java heap space error and the kernel log for a killed process. The fix is more memory, typically 2GB or more available to Metabase, plus an explicit maximum heap in JAVA_OPTS.
Can I use the default H2 database in production?
No. H2 is the evaluation default and it stores everything inside the container, so your dashboards vanish when the container is recreated. It can also corrupt under load or an unclean shutdown with no good repair path. Set MB_DB_TYPE to postgres before your first launch.
How do I move an existing Metabase from H2 to Postgres?
Metabase ships a migration command that reads the existing H2 file and writes it into the target application database. Stop Metabase, run the migration with the target database environment variables set, then start it pointed at Postgres. Do it early, because it gets slower as the H2 file grows.
How much memory does Metabase need?
Plan for around 2GB available to Metabase itself, more if several people run large queries concurrently. The JVM wants roughly 1GB of heap plus overhead. If Metabase and its Postgres instance share one small box, both are competing for the same memory and the JVM is what gets killed.
Why do long queries fail through nginx?
The proxy read timeout. Analytical queries frequently exceed a 60-second default, and nginx closes the connection and returns a gateway error while the query is still running. Raise proxy_read_timeout to several minutes on the Metabase location block.