The Best Metabase Hosting Platforms in 2026
Metabase has, without much competition, the best first five minutes in business intelligence. You download a jar, you run java -jar metabase.jar, a browser tab opens, you paste in a warehouse connection string, and roughly ninety seconds later somebody who has never written SQL is dragging a bar chart around. There is no cluster, no operator, no Helm values file with four hundred lines of commented-out YAML. It is one process. It is genuinely delightful.
It is also the reason so many Metabase installations are quietly terrifying. A tool that starts that easily gets started by someone who was not planning to run infrastructure, on a machine nobody wrote down, with the defaults it shipped with. Six months later it is load-bearing for the weekly revenue review, it holds the credentials to four production databases, and its entire state is in a file called metabase.db.mv.db that is not in any backup you can name.
I'm Ajay, I build PandaStack, and we host Metabase instances as ordinary git-deployed apps, so treat me as an interested party throughout. This is a qualitative comparison rather than a price sheet: vendor pricing, free-tier limits and which features sit behind which edition all change on their schedule, not mine, so verify anything that matters against the vendors' current documentation before you commit. What I can be precise about is the shape of the problem, which barely changes at all.
What you are actually hosting
Metabase is a Clojure application running on the JVM, distributed as a single uber-jar or a single container image. Architecturally it is a stateful web app with three things bolted to it, and almost every production surprise traces back to one of them.
The first is its application database — the app DB — which is the only place Metabase's own life exists. It is worth listing what lives in there, because people consistently underestimate it:
- Every question, model, dashboard, collection and subscription anybody has ever built. This is the bit that took your analysts a year.
- Users, groups, permission graphs and API keys — that is, your access-control model, expressed as rows.
- The connection details for every data source you have added, including credentials, encrypted with a key you supply (and, if you did not supply one, not encrypted at all).
- Cached query results and the field metadata Metabase collects when it syncs and scans your warehouse schemas, which is what makes filter dropdowns work.
- The task scheduler's state — the quartz tables that decide when subscriptions send and when syncs run.
The second is the JVM heap, which is not merely an implementation detail because query results pass through it. The third is the set of outbound connections to your data sources, which is a polite way of saying Metabase is a credential vault with a SQL console attached to the front.
So 'where should I host Metabase' decomposes into three questions that get jammed together and answered by accident:
- Where does the application database live, and who backs it up? The default answer is 'an embedded H2 file, and nobody'.
- How much memory does the process get, who decides, and does the JVM agree with the kernel about the number? Disagreement here is how you meet exit code 137.
- Who can reach the instance, and what can a person who reaches it do? Metabase can run arbitrary SQL against everything you have connected, and it can publish query execution to the open internet if you ask it nicely.
Everything below is those three axes, then an honest round-up of where you might run the thing.
Axis one: the application database, and the H2 default
Out of the box, Metabase stores its own state in an embedded H2 database — a file called metabase.db.mv.db, created next to wherever the jar was run, or inside the container's writable layer if you used the image. This is an excellent default for evaluating a product and a genuinely dangerous one for running a business on, and Metabase's own documentation is unusually blunt about not using it in production. The problem is that nothing stops you, and everything about the first five minutes encourages you.
There are three separate failure modes, and they tend to arrive together on a day you were doing something else.
- It ties the install to a disk. If Metabase runs in a container with no persistent volume — and the quickstart command does not mount one — then the whole install lives on a layer that vanishes on the next docker rm, redeploy or node replacement. 'We redeployed and Metabase came back as a fresh setup wizard' is the single most common Metabase incident there is.
- It rules out ever running more than one instance. H2 is an embedded, single-process database. Beyond capacity, this also means you cannot do a zero-downtime deploy or a blue-green anything, because the two processes cannot share the state.
- It makes backups awkward in exactly the way that guarantees they do not happen. Copying a live H2 file is not a backup, it is a coin flip; an unclean shutdown mid-write is a well-documented way to end up with a file that will not open. A proper backup means stopping the process, which nobody schedules.
The fix is one block of configuration: point Metabase at Postgres (MySQL and MariaDB are also supported) using the MB_DB_ environment variables. It is not a big change. It is only painful if you do it late, which is the entire argument for doing it on day zero.
#!/usr/bin/env bash
# Write the env file, then run the image. Everything Metabase needs to be an
# install rather than a demo is in here.
set -euo pipefail
cat > metabase.env <<'ENV'
# 1. THE APPLICATION DATABASE.
# Omit these and Metabase silently creates an H2 file inside the container,
# on the writable layer that `docker rm` deletes. Everything you built --
# questions, dashboards, users, permissions -- lives in here.
MB_DB_TYPE=postgres
MB_DB_HOST=metabase-app-db.internal.example.com
MB_DB_PORT=5432
MB_DB_DBNAME=metabase
MB_DB_USER=metabase
MB_DB_PASS=change-me
# 2. ENCRYPTION KEY for the warehouse credentials stored in the app DB.
# Generate once, keep it in a secret store, never regenerate on redeploy.
# Lose it and every saved data-source connection becomes undecryptable.
MB_ENCRYPTION_SECRET_KEY=change-me-too
# 3. SITE URL. Subscription emails, alert links and embed URLs are built from
# this. Get it wrong and every link in every email points at localhost.
MB_SITE_URL=https://metabase.internal.example.com
MB_JETTY_PORT=3000
# 4. HEAP. Choose it deliberately instead of letting the JVM infer a quarter of
# a cgroup limit. Roughly 70% of the machine's RAM, leaving the rest for
# metaspace, thread stacks, JIT code cache, direct buffers and the OS.
JAVA_OPTS=-Xmx2800m -XX:MaxMetaspaceSize=512m -XX:+ExitOnOutOfMemoryError
ENV
# Pin the version. ':latest' is how you get a surprise schema migration on a
# Tuesday afternoon, and Metabase schema migrations are not reversible.
docker run -d --name metabase \
--env-file metabase.env \
--memory 4g \
-p 3000:3000 \
metabase/metabase:v0.XX.Y
# The readiness endpoint. It returns 200 only once migrations have finished,
# which on a first boot or a major upgrade is emphatically not instant.
curl -fsS http://localhost:3000/api/healthMigrating off H2 is a real migration, so do it on day zero
Metabase does ship a migration path — a load-from-h2 command that reads an existing H2 file and populates a fresh Postgres app DB — and it works. But it is a stop-the-world operation with a couple of edges that catch people, and the amount of content you are moving only grows. The version of this task you run in week one takes ten minutes. The version you run in year two involves scheduling downtime for a tool the whole company uses at 9am.
#!/usr/bin/env bash
# Migrating off H2 -- the support ticket everyone eventually files.
set -euo pipefail
# 1. Stop Metabase first. A live H2 file is not a consistent source, and
# "we copied it while it was running" is the first line of the incident doc.
docker stop metabase
# 2. The target Postgres must exist and be EMPTY. load-from-h2 populates a
# fresh app DB; it does not merge into one that already has content.
psql "$TARGET_DSN" -c 'select count(*) from information_schema.tables
where table_schema = current_schema()'
# 3. Point the JVM at the NEW Postgres, hand it the OLD H2 file. The path is
# given WITHOUT the .mv.db suffix -- the step people get wrong every time.
export MB_DB_TYPE=postgres
export MB_DB_HOST=metabase-app-db.internal.example.com
export MB_DB_PORT=5432
export MB_DB_DBNAME=metabase
export MB_DB_USER=metabase
export MB_DB_PASS="$TARGET_PASSWORD"
java -jar metabase.jar load-from-h2 /metabase-data/metabase.db
# 4. Boot against Postgres and log in before you delete anything at all.
# Keep the H2 file until a human has clicked around for a week.Two details from that script are worth repeating because they are where the tickets come from. The path you hand load-from-h2 omits the .mv.db suffix — you pass /path/to/metabase.db even though the file on disk is /path/to/metabase.db.mv.db. And the target database must be empty; the command populates a new app DB rather than merging into an existing one, so a half-started Metabase that already ran its migrations against the target will make it fail. Check the exact invocation against Metabase's current documentation before you run it in anger, because the surrounding flags do move between versions.
Axis two: the JVM heap versus the machine's memory
There are two memory numbers in a Metabase deployment and you need to know both. One is the maximum heap the JVM will allocate. The other is the hard limit the kernel will enforce on the process — a container memory limit, a cgroup, or in a microVM the actual size of the machine's RAM. Nearly every Metabase memory incident is these two numbers disagreeing.
If you set neither, a modern container-aware JVM will look at the limit it can detect and reserve a fraction of it as max heap — historically about a quarter. On a 2 GiB container that is roughly 512 MiB of heap for an application that materialises query results in memory, and the symptom is not a crash. The symptom is a Metabase that gets slower and slower under a handful of concurrent dashboard loads while the garbage collector burns CPU trying to make room, until somebody says 'Metabase feels weird today' and nobody investigates.
The opposite mistake is worse. Setting -Xmx equal to the container limit feels tidy and is a trap, because heap is not the process's memory footprint. The JVM also wants metaspace for loaded classes, a thread stack for each of Jetty's request threads, a JIT code cache, direct byte buffers, whatever the JDBC drivers allocate off-heap, and the jar's own mapped pages. Add the OS underneath it. A heap ceiling equal to the machine's RAM means the process will exceed the limit before the garbage collector ever feels pressure, and at that point control passes to a component with no interest in your uptime.
The two failure modes are worth distinguishing because they look nothing alike in a postmortem. A java.lang.OutOfMemoryError is the civilised one: you get a stack trace, you can get a heap dump, you can see which query ate the world. A kernel OOM kill is the other one — exit code 137, no stack trace, no dump, and a log file whose final line is about something completely unrelated. The JVM asked politely for memory it had been promised, and the kernel, which never agreed to any of this, ended the conversation.
A container memory limit is a polite suggestion to the JVM and a binding contract with the kernel. Only one of them enforces it, and it is not the one printing your stack traces.
A workable rule: pick the machine size first, then set -Xmx to roughly 70% of it, then leave -XX:MaxMetaspaceSize set so metaspace cannot grow without bound. For a typical internal Metabase serving a few dozen people, a 4 GiB machine with a 2.8 GiB heap is comfortable. Small instances with two or three users survive on less; instances where people export large CSVs need more, for reasons that are entirely about the next paragraph.
What actually fills the heap
Query result sets. That is the short answer and it explains most of the variance between installs. Metabase pulls rows from your warehouse into the JVM before rendering or exporting them, so heap consumption scales with how much data your users ask for rather than with how many users you have. A dashboard with twenty-five cards fires roughly twenty-five queries when it loads; five people opening that dashboard at the same time is a hundred result sets in flight. And the genuinely dangerous button is the download: a CSV export of a large unaggregated table is, from the JVM's point of view, a request to hold that table in memory.
Metabase does apply row limits to protect itself — there are separate limits for aggregated and unaggregated query results, exposed as environment variables, and separate, much larger limits for downloads. The defaults and the exact variable names have changed across versions, so look them up for the version you run rather than trusting a blog post, including this one. The point to internalise is that the download limit is the one that determines your worst case, and it is a much bigger number than the display limit.
The boot-time trap: schema migrations versus your health check
On startup Metabase runs Liquibase migrations against its app DB, and /api/health does not return 200 until they finish. On a small install that is a few seconds. On a major version upgrade against an app DB with years of content, it can be minutes. If your platform's health check gives the process thirty seconds and then restarts it, you have built a machine that repeatedly interrupts its own database migration and starts it over, which is a fascinating thing to watch and an unpleasant thing to recover from.
Set a generous startup grace period — a startup probe on Kubernetes, a long initial health-check window elsewhere — and a tighter check afterwards. And take a copy of the app DB before any major upgrade, because Metabase schema migrations are not designed to be rolled back. The upgrade path is forward-only; your safety net is the backup.
Axis three: a credential vault with a SQL console attached
Metabase's threat model is not the same as a normal internal web app's, and the difference is worth stating plainly. Metabase holds working credentials for every data source you have connected. It exposes a native SQL editor to anyone whose group has that permission. And through public links and embedding, it can be configured to execute queries on behalf of people who do not have an account at all. Each of those is a legitimate, useful feature. Together they mean an exposed Metabase is not an information leak, it is a query engine you have donated.
Public links are the one that gets shared into an incident. Publishing a question or a dashboard produces a URL that anybody holding it can load, and loading it executes the underlying query server-side. That is exactly what you want for a public status board and exactly what you do not want when someone pastes the link into a Slack channel that later acquires a guest user, or into a ticket in a system your vendors can read. The links do not expire on their own. Audit the ones that already exist; most installs have some, and most people have forgotten which.
Embedding is the same idea, formalised. Static (signed) embedding gives you a URL signed with a server-side key and locked-down parameters, and is available in the open-source edition. Interactive embedding, along with data sandboxing — the feature that filters rows by a user attribute so each customer sees only their own data — sits in the paid editions; check the current edition split on Metabase's pricing page rather than trusting any third-party summary, because it moves. If your multi-tenant story depends on a signed parameter being applied correctly, then that parameter is now a security control, and it deserves a test that tries to break it.
The practical hardening list is short and mostly boring, which is the good kind:
- Give every data source connection its own read-only warehouse user. The SQL editor is a SQL editor; assume anything the connection can do, a user with native query permission can do. If you use uploads or model persistence, scope those write privileges to a dedicated schema rather than granting broad write access.
- Treat 'native query editing' as a privileged permission and grant it to a small group, not to everyone. Most people building dashboards never need it.
- Put real SSO in front. Metabase supports several identity integrations, with the enterprise-grade options in the paid tiers — verify which ones your edition includes before you plan around them. Whatever you use, do not run an internal instance on shared admin credentials.
- Keep the instance behind a VPN or identity-aware proxy if it is internal. Then a misconfiguration inside Metabase is a misconfiguration, not a disclosure.
- Set the encryption key, and know that rotating it is a specific operation with its own command rather than an environment-variable edit. Look up rotate-encryption-key for your version before you need it, not during.
- Turn off public sharing unless you deliberately want it, and review existing public links on a schedule.
Metabase is the one internal tool that knows every password your data team has ever typed. Host it accordingly.
The hosting options
Metabase Cloud
The first-party managed offering, run by the people who write the software, and the option to beat. It takes all three axes off your plate at once: they own the application database and its backups, they own the JVM sizing, they own version upgrades including the schema migrations that make upgrades interesting, and they terminate TLS. Paid-edition features come bundled according to plan, which for a lot of teams is the actual reason to choose it — the embedding and permissions features people want are on that side of the line anyway.
I want to be straightforward here rather than perform a comparison: for most teams who just want Metabase for internal analytics, this is the right default, and the burden of proof is on the alternatives. The reasons to look elsewhere are specific rather than general. Your warehouse sits in a private subnet and you would rather not expose it or maintain a tunnel. Your compliance posture wants the BI layer inside your own network boundary. Your seat count makes the arithmetic unattractive. Or you are embedding Metabase into your own product on a per-customer basis and need to control the topology yourself. Any of those is a real reason. 'Self-hosting is cheaper' on its own usually is not, once you price the hour a month somebody spends on upgrades.
Kubernetes
A Deployment, a Service, a Secret and an external Postgres. Metabase behaves well here because it is a single stateless-ish process once the app DB is external, and if you already run a cluster with ingress, secrets management and a Postgres operator, adding Metabase is an afternoon and a pull request.
Three things to get right. Set resources.limits.memory and -Xmx together and deliberately, with the heap comfortably under the limit — the kernel enforces the limit, the JVM merely believes -Xmx, and when they disagree the kernel wins without explaining itself. Use a startupProbe with a generous failureThreshold pointed at /api/health so migrations are not interrupted by the liveness probe. And do not casually set replicas to two: check your version's guidance on running multiple instances against one app DB, because scheduled tasks coordinate through those quartz tables and getting it wrong means duplicate subscription emails at best.
The usual caveat applies with full force: this is the right answer if the cluster already exists and is load-bearing. Metabase is not a workload that justifies adopting a control plane. If the sentence 'we should stand up Kubernetes for our BI tool' has been said out loud in a meeting, something has gone wrong upstream of the hosting decision.
A plain VM
Underrated, and for a small team frequently the correct answer. Install a JRE, drop the jar in /opt, write a twenty-line systemd unit with the environment file, put Caddy or nginx in front for TLS, and point MB_DB_ at your cloud provider's managed Postgres. The total ongoing burden is OS patches and a version bump a few times a year, and the whole thing is legible: one process, one config file, one database.
The downsides are the ones every pet server has. You own the box and its patching. The upgrade is a manual sequence somebody has to remember, including the backup beforehand. 'How do we rebuild this if the instance is lost' has a good answer only if somebody wrote one down, and the honest base rate on that is not encouraging. And it runs, and bills, twenty-four hours a day regardless of whether anyone opened a dashboard — which for an internal tool used between nine and six on weekdays means you are paying for roughly three and a half times more machine-hours than you use.
Container PaaS
Render, Railway, Fly, Northflank and the rest of that class all run the official Metabase image directly: point the platform at metabase/metabase, attach its managed Postgres, paste in the environment variables, done. For teams who want to self-host without owning a VM, this is the best effort-to-outcome ratio available, and the deploy story — push, build, health-check, swap — is exactly what you want for a version bump.
Two things to check before you commit. First, how memory is enforced: on most container platforms your instance is a cgroup on a shared host kernel, so the limit is real but the machine underneath it is not exclusively yours, which makes your -Xmx headroom calculation matter more rather than less. Second, if the platform offers sleep or scale-to-zero, understand what stops when the instance sleeps — which is the next paragraph's problem on every platform, mine included. Also confirm that the persistent disk they offer you is not quietly holding an H2 file, because a platform that gives you a volume makes the wrong default look survivable for just long enough.
PandaStack
This is my product, so read the following as an interested party's description and discount accordingly. A PandaStack app is a full Ubuntu userspace inside a Firecracker microVM, deployed from a git push, with managed Postgres attachable by environment variable. Managed Postgres creation takes 30–90 seconds; app instances restore from a snapshot with a p50 of 179ms and a p99 around 203ms, with a first cold boot of around 3 seconds.
Three properties map onto the three axes above, which is why Metabase is a good fit rather than merely a possible one.
On the app DB: the managed Postgres supports cloning into a new database, including point-in-time, so the small, precious, chronically un-backed-up application database gets a rehearsal path. Before a major version upgrade, clone the app DB, point a throwaway Metabase at the clone, let Liquibase do its worst, and see what happens — while the real instance keeps serving. That turns a forward-only migration into something you have already watched succeed. It is not a novel idea; it is just an idea nobody bothers with when restoring a backup is a forty-minute manual job.
On memory: a microVM's RAM is the machine's RAM. It is decided when the VM boots and it is not a share of a host that has been cheerfully oversubscribed to whoever else landed there. That does not make the JVM's arithmetic any less your problem — you still set -Xmx to about 70% of it — but it means the number you size against is a number that stays true. The failure mode where your instance is fine in testing and OOM-killed in production because a neighbour got busy is one you do not have.
On idle: an internal Metabase is opened by nine people between nine and six and stares into space for the other sixteen hours. Apps sleep when idle and wake on the next request, so the wallpaper hours cost nothing. The caveat has to be stated as plainly as the benefit: a sleeping Metabase does not send dashboard subscriptions, does not evaluate alerts, and does not run its scheduled sync and scan of your warehouse schemas. Wake-on-request is fine for humans opening dashboards. It is not a cron. If a 7am email digest is load-bearing for someone's morning, that instance is an always-on instance, and any platform that tells you otherwise is selling you something.
On isolation: if you are running Metabase per customer — an embedded analytics product where each tenant gets their own instance with their own app DB and their own warehouse credentials — then each instance is a separate microVM with its own kernel and its own network namespace, rather than a container sharing a kernel with the next tenant's. That is the coarse, instance-level version of an argument I have made at a finer grain elsewhere about isolating individual tenant queries; here the unit is simply the whole Metabase. The relevant property is that a container is a polite suggestion to a shared kernel, and a microVM is a hardware boundary.
Where PandaStack is the wrong choice: if what you want is for somebody else to own Metabase's version upgrades, its backups and its uptime, we do not do that. We give you a machine, a database and a deploy pipeline; you still run Metabase. That is Metabase Cloud's job and they are good at it. I would genuinely rather you bought that than self-hosted a jar you forget to patch.
The options side by side
- Metabase Cloud — App DB: theirs, managed and backed up, invisible to you. Memory control: none needed; sizing is their problem. Idle cost: full price whether or not anyone logs in. Per-tenant isolation: instance-level, on their topology. Ops burden: essentially zero, including the version upgrades that are the real work.
- Kubernetes — App DB: an external Postgres you provision and back up yourself. Memory control: precise, via resource limits plus -Xmx, and you must set both or the kernel decides. Idle cost: full price unless you have built scale-to-zero yourself. Per-tenant isolation: namespaces and network policies on a shared node kernel. Ops burden: low if the cluster exists and is somebody's job; high if it does not.
- A plain VM — App DB: managed Postgres from your cloud provider, or an H2 file if you were in a hurry, which is the whole problem. Memory control: total, since the box is yours. Idle cost: full price, twenty-four hours a day. Per-tenant isolation: one VM per tenant if you want it, provisioned by hand. Ops burden: small but permanent — patching, upgrades and a rebuild procedure only you know.
- Container PaaS (Render, Railway, Fly, Northflank-class) — App DB: the platform's managed Postgres, attached by environment variable. Memory control: a cgroup limit on a shared host kernel; -Xmx headroom matters more, not less. Idle cost: some platforms sleep idle services, some do not — check, and check what stops when they do. Per-tenant isolation: container-level on a shared kernel. Ops burden: low; deploys and TLS are handled, upgrades are a version bump you trigger.
- PandaStack — App DB: managed Postgres with clone and point-in-time restore, so upgrades can be rehearsed on a copy. Memory control: the microVM's RAM is the machine's RAM, not a share of an oversubscribed host. Idle cost: sleeps when idle, wakes on request — but a sleeping instance sends no subscriptions and runs no syncs. Per-tenant isolation: a separate Firecracker microVM with its own kernel per instance. Ops burden: low but real — we run the machine and the database, you still run Metabase.
A self-hosted shape that survives contact with production
If you land on self-hosting, the shape is small and the discipline is simple: nothing durable on the instance's disk, every setting in the environment, and the heap sized against a number you actually know. Here is what that looks like end to end on PandaStack; the pattern transfers to any platform that can run a real Linux process and hand you a Postgres.
#!/usr/bin/env bash
set -euo pipefail
# 1. Managed Postgres for Metabase's own state. 30-90 seconds to create,
# because it is a real Postgres in its own microVM, not a schema in a pool.
pandastack db create --label metabase-app-db --size 1g
pandastack db get metabase-app-db # -> host, user, password, dsn
# 2. The app itself, from a repo holding a build script and a start script.
# No Dockerfile: the platform detects the runtime and runs your commands.
pandastack apps create --name metabase \
--git-url https://github.com/acme/metabase-host \
--branch main \
--start-cmd './start.sh'
# 3. Wire the app DB in as environment. These are the same MB_DB_* variables
# the container image reads -- nothing PandaStack-specific about them.
pandastack apps env set metabase MB_DB_TYPE=postgres
pandastack apps env set metabase MB_DB_HOST='<db-host>'
pandastack apps env set metabase MB_DB_DBNAME=pandastack
pandastack apps env set metabase MB_DB_USER=pandastack
pandastack apps env set metabase MB_DB_PASS='<db-password>'
pandastack apps env set metabase MB_ENCRYPTION_SECRET_KEY='<generated-once>'
# 4. Before a major Metabase upgrade: clone the app DB and rehearse on the
# copy. The source database is untouched; the clone is a new database id.
pandastack db clone metabase-app-db --label metabase-upgrade-rehearsal#!/usr/bin/env bash
# start.sh -- runs inside the app's Firecracker microVM.
# Every byte of durable state lives in the managed Postgres, so this machine is
# disposable: it can be redeployed, rolled back, or slept without consequence.
set -euo pipefail
export MB_SITE_URL="$APP_URL"
export MB_JETTY_HOST=0.0.0.0
export MB_JETTY_PORT="${PORT:-3000}"
# The microVM's RAM is a real, fixed number decided when the machine boots --
# not a cgroup limit on a host that has been cheerfully oversubscribed. That
# means -Xmx can be set against a figure that will still be true at 3am.
export JAVA_OPTS="-Xmx2800m -XX:MaxMetaspaceSize=512m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/metabase-heap.hprof"
# Give migrations room on a major upgrade. Health checks should be generous at
# startup and strict afterwards; Liquibase does not care about your SLO.
exec java -jar /opt/metabase/metabase.jarThe important property of that arrangement is not any individual line. It is that once no durable state lives on the instance, the instance becomes disposable — and a disposable instance is one you can redeploy without ceremony, roll back without archaeology, and sleep while nobody is looking without it being a risk. Every good property of self-hosted Metabase follows from getting the app DB off the box.
Add two operational habits and you are genuinely fine. Take a copy of the app DB before every version bump, because Metabase's migrations are forward-only and the copy is your only rollback. And put a monitor on the health endpoint that alerts on sustained failure rather than a single blip, so an OOM-restart loop announces itself instead of being discovered by an executive on a Monday.
How to choose in ten minutes
- Ask whether your warehouse is reachable from the public internet or willing to be. If the answer is no and you do not want to run a tunnel, that rules out managed options and the decision is mostly made.
- Ask which Metabase features you need, and check which edition they are in. Interactive embedding, data sandboxing and the enterprise SSO integrations sit in the paid tiers; if you need them, you are buying a licence regardless and the hosting question narrows considerably.
- Decide whether this instance must do things when nobody is watching. Subscriptions, alerts and scheduled syncs mean always-on. Dashboards that humans open mean it can sleep, and sleeping is the single biggest cost lever for an internal tool.
- Commit to Postgres for the app DB and to a backup you have actually restored once, whichever host you pick. Everything else on this list is reversible; this one is the one that becomes expensive to fix later.
- If nothing above disqualified it, buy Metabase Cloud. Self-host when you have a specific reason — network topology, per-tenant instances, cost at your seat count — and not because the jar looked easy to run.
The short version
Metabase Cloud if you want the whole thing owned by the people who write it, which is most teams and is not an admission of defeat. Kubernetes if the cluster already exists and Metabase is one more well-behaved Deployment. A plain VM if you are small, you like legible systems, and somebody has written down the rebuild procedure. A container PaaS if you want self-hosting without a pet server. And a microVM platform with managed Postgres if you want an internal instance that costs nothing overnight, an app DB you can clone before an upgrade, and a hard isolation boundary per tenant when you are shipping Metabase to customers.
Whichever you pick, the same three things determine whether you are still happy in a year. The app DB belongs in Postgres and in a backup you have restored at least once. The heap belongs at about 70% of a machine size you chose on purpose. And the instance belongs behind something, with read-only warehouse users, because the SQL editor is a SQL editor and the public link nobody remembers creating is still live. Get those right and the hosting choice becomes what it should be: reversible.
Frequently asked questions
Can I run Metabase in production with the default H2 database?
You can, in the sense that nothing stops you, and Metabase's own documentation advises against it. The embedded H2 file ties your entire install — every dashboard, user, permission and saved warehouse credential — to one disk, which in a container means the writable layer that a redeploy deletes. It also prevents running more than one instance, and it makes backups awkward enough that they usually do not happen, since copying a live H2 file is not a reliable backup. Point Metabase at Postgres or MySQL using the MB_DB_TYPE, MB_DB_HOST, MB_DB_PORT, MB_DB_DBNAME, MB_DB_USER and MB_DB_PASS environment variables on day one. Migrating later is possible with the load-from-h2 command, but it is a stop-the-world operation against a database that only grows, and it is the classic Metabase support ticket for a reason.
How much memory does Metabase need, and what should JAVA_OPTS be?
There are two numbers and they must agree. The kernel enforces the machine or container memory limit; the JVM only believes -Xmx. If you set neither, a container-aware JVM reserves roughly a quarter of the detected limit as heap, which on a 2 GiB container is about 512 MiB and produces a Metabase that garbage-collects itself into slowness rather than failing cleanly. If you set -Xmx equal to the limit, the process gets OOM-killed, because heap excludes metaspace, thread stacks, the JIT code cache and off-heap driver buffers. A workable rule is to pick the machine size first and set -Xmx to about 70% of it — a 4 GiB machine with a 2.8 GiB heap suits a typical internal instance. Heap consumption tracks result-set size rather than user count, so installs where people export large CSVs need more. Add -XX:+ExitOnOutOfMemoryError so an exhausted heap restarts instead of limping along answering health checks while every request fails.
Is Metabase Cloud worth it compared with self-hosting?
For most teams running Metabase as an internal analytics tool, yes, and the burden of proof is on the alternatives. Cloud removes all three of the things that actually bite: the application database and its backups, the JVM sizing, and the version upgrades, which include forward-only schema migrations that are the genuinely risky part of running Metabase yourself. The reasons to self-host are specific rather than general — a warehouse in a private subnet you do not want to expose, a compliance requirement to keep the BI layer inside your network, an unattractive seat-count calculation, or an embedded-analytics product where you run an instance per customer and need to control the topology. 'It is cheaper' on its own rarely survives contact with the hour a month somebody spends on upgrades. Verify current pricing and the edition feature split against Metabase's own site before deciding, since both move.
Are Metabase public links and embedding safe to use?
They are safe in the sense that they do what they say, and risky in the sense that what they say is 'execute this query for anyone holding this URL'. A public link does not expire on its own, so the failure mode is a link pasted into a Slack channel that later acquires a guest, or into a ticketing system your vendors can read. Static, signed embedding narrows this by signing the URL server-side with locked parameters, and it is available in the open-source edition; interactive embedding and data sandboxing, which filters rows by user attribute for multi-tenant cases, sit in the paid editions — check the current split on Metabase's pricing page. If your tenant separation depends on a signed parameter being applied correctly, treat that parameter as a security control with a test that tries to defeat it. Independently, give each data source a read-only warehouse user and restrict native query permission to a small group, because the SQL editor will do exactly what it is asked.
Can I run Metabase on PandaStack?
Yes. A PandaStack app is a full Ubuntu userspace inside a Firecracker microVM deployed from git, so you install a JRE and the Metabase jar in the build step and start it with MB_DB_ environment variables pointing at a managed Postgres, which takes 30 to 90 seconds to create. Because the microVM's RAM is the machine's RAM rather than a share of an oversubscribed host, -Xmx has a stable number to aim at. The managed Postgres supports cloning and point-in-time restore, which gives you a rehearsal path for Metabase's forward-only schema migrations: clone the app DB, upgrade a throwaway instance against the copy, then do it for real. Apps sleep when idle and restore from snapshot with a p50 of 179ms, so an internal instance costs nothing overnight — with the caveat that a sleeping Metabase sends no dashboard subscriptions, evaluates no alerts and runs no scheduled schema syncs, so anything that must happen at 7am needs an always-on instance.
Keep reading
- App hosting on PandaStack — Git-driven deploys into a full Linux microVM — where the jar runs.
- Managed Postgres with branching and PITR — The right home for Metabase's application database.
- The best Grafana hosting platforms in 2026 — The same question for the dashboard tool next to this one.
- The OOM killer inside a microVM — What exit code 137 means when the JVM guessed wrong.
- Per-tenant analytics queries in isolated microVMs — The finer-grained version of the embedded-analytics argument.
- How to restore Postgres to a point in time — The rehearsal you want before a forward-only schema migration.
49ms p50 cold start. Fork, snapshot, and scale to zero.