Use case · Business intelligence

Metabase self hosted: PostgreSQL app DB, Docker and a locked-down first run

A self-hosted Metabase in production is two things: the Metabase application (one JAR, or the official Docker image) and an application database that holds every question, dashboard and account. The default H2 file is for local demos; Metabase's docs say to avoid it in production and recommend PostgreSQL. This page runs both on one machine with Docker, keeps port 3000 on 127.0.0.1, creates the first admin over an SSH tunnel, then puts nginx and HTTPS in front.

See the machines

Two databases, two jobs

Metabase keeps its own state (users, saved questions, dashboards, collections, permissions, the connection details of your data sources) in the application database. The data you chart lives elsewhere, in what the docs call the data warehouse. Metabase reads the app DB settings only at startup, so it cannot be switched while the process runs.

App DB optionMetabase docs status
PostgreSQL 14 or newerRecommended for production
MySQL 8.4.0+ or MariaDB 10.6.0+Also works for production; utf8mb4 required
H2 (embedded file)Default for local demos; avoid in production

On Docker the H2 file sits inside the container, and the docs warn that removing the container loses it. If a team has already built dashboards on H2, the load-from-h2 command copies them into an empty PostgreSQL database, run with the same Metabase version that last wrote the H2 file. Do that before upgrading, not during.

Memory, Java and the machine

Metabase is a Java application. For the JAR, the docs name Java 25 as the supported runtime and recommend the Eclipse Temurin build. By default the JVM reserves about one fourth of the machine's RAM and does not hand it back, so an 8 GB box shows 2 GB in use whatever Metabase is doing.

  • Raise the heap with -Xmx, or JAVA_OPTS=-Xmx4g for the container, when the heap graph shows a sawtooth of constant garbage collection or the logs report the heap running out.
  • The docs' rule: leave 1 to 2 GB for everything else, so -Xmx1g on a 2 GB machine, 2g on 4 GB, and so on.
  • On this layout PostgreSQL shares the machine. Our suggestion, not Metabase's: size the heap after giving the app DB its own room, and keep a large warehouse on a separate host.

A whole machine suits this because the JVM sizes itself from the RAM it sees, and on a dedicated server no other tenant's processes share that RAM, the cores or the disks, and you hold root. A single-socket machine is the usual base for an application with its database beside it; a dual-socket box carries Metabase next to a busy warehouse. If PostgreSQL itself is the reporting source, the PostgreSQL page covers tuning it; for event-scale analytics, Metabase connects to ClickHouse as a data source.

Firewall, secrets and containers

Run this as root (sudo -i) on Ubuntu with Docker Engine 28 or newer; docker version shows it, and Docker's own apt repository carries current releases. The firewall goes on first, with IPv6 rules enabled before any allow rule, because ufw writes IPv6 copies only for rules added while IPV6=yes. The two env files hold the passwords and keys at mode 600; Docker reads them literally, so values carry no quotes and no spaces. Writing them with heredocs keeps the secrets out of ps and out of shell history; the block sets umask 077 only while it writes them and puts it back to 022 afterwards.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw allow 80,443/tcp
ufw --force enable
umask 077
mkdir -p /opt/metabase && cd /opt/metabase
DBPASS=$(openssl rand -hex 24)
cat > pg.env <<EOF
POSTGRES_USER=metabase
POSTGRES_DB=metabaseappdb
POSTGRES_PASSWORD=$DBPASS
EOF
cat > metabase.env <<EOF
MB_DB_TYPE=postgres
MB_DB_HOST=mb-postgres
MB_DB_PORT=5432
MB_DB_DBNAME=metabaseappdb
MB_DB_USER=metabase
MB_DB_PASS=$DBPASS
MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)
MB_SESSION_SECRET_KEY=$(openssl rand -base64 32)
MB_SITE_URL=https://bi.example.com
EOF
chmod 600 pg.env metabase.env
unset DBPASS
umask 022
docker network create metabase-net
docker run -d --name mb-postgres --restart unless-stopped \
  --network metabase-net --env-file /opt/metabase/pg.env \
  -v mb-pgdata:/var/lib/postgresql/data postgres:16
docker run -d --name metabase --restart unless-stopped \
  --network metabase-net --env-file /opt/metabase/metabase.env \
  -p 127.0.0.1:3000:3000 metabase/metabase:latest
docker logs -f metabase

A -p 3000:3000 mapping with no address publishes on every host address, 0.0.0.0 and [::]. Published IPv4 traffic skips ufw's rules, and IPv6 does too when Docker's own IPv6 networking is on (otherwise the [::] listener is docker-proxy, which ufw filters). Binding to 127.0.0.1 keeps port 3000 local, and Engine 28+ is what makes a 127.0.0.1 binding truly local-only (moby#45610). PostgreSQL publishes no port at all. Pin a release tag instead of latest once you know which version you run.

MB_ENCRYPTION_SECRET_KEY makes Metabase encrypt the connection details of each data source with AES256 + SHA512 before writing them to the app DB. Without that key they cannot be decrypted; lose or change it and every encrypted connection has to be re-entered in the Admin Panel. Setting it on a fresh install is all that is needed; an existing instance needs the one-time enable-encryption command first. The security guide also asks for MB_SESSION_SECRET_KEY on releases that support it. The JAR works the same way as a systemd service reading an EnvironmentFile; note that the JAR listens on localhost by default, the Docker image on 0.0.0.0.

The first admin, before anyone else

The account created in the setup wizard is an admin by default, and it adds users, connects databases and sets up email. Whoever reaches the wizard first gets that account, so finish it while nothing but the machine itself can reach port 3000.

  1. From your workstation, open a tunnel with your own login: ssh -L 3001:127.0.0.1:3000 you@server.
  2. Browse to http://localhost:3001, click through the wizard and create the admin with a strong password.
  3. Add your first data source with a dedicated read-only database user limited to the schemas Metabase needs, as the security guide recommends; or skip it and use the Sample Database.
  4. Only then publish the instance through the proxy below.

nginx and HTTPS in front

The security guide says to keep Metabase's port off the internet behind a reverse proxy you control, to set MB_SITE_URL to the https address, and to have the proxy send X-Forwarded-Proto. Point a DNS record at the server, then:

apt install -y nginx certbot python3-certbot-nginx
cat > /etc/nginx/sites-available/metabase <<'EOF'
server {
    listen 80;
    listen [::]:80;
    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;
    }
}
EOF
ln -s /etc/nginx/sites-available/metabase /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d bi.example.com

Once HTTPS answers, the guide's MB_REDIRECT_ALL_REQUESTS_TO_HTTPS=true can go into metabase.env; recreate the container with the same docker run line for any env change. Open Source sign-in hardening from the same guide: MB_PASSWORD_COMPLEXITY=strong-enough, few admins, and deactivated accounts for people who leave.

Backups and upgrades

Metabase keeps all of its runtime state in that one SQL database, so a dump of it is the backup. Create the target directory first (mkdir -m 700 /var/backups/metabase), then add this file as /etc/cron.d/metabase-backup:

15 2 * * * root umask 077; docker exec mb-postgres pg_dump -U metabase -Fc metabaseappdb > /var/backups/metabase/app-$(date +\%F).dump

Copy the dumps off the machine yourself, and keep a copy of metabase.env somewhere separate: a dump restored without its encryption key comes back with unreadable connection details. Test a restore into a scratch database with pg_restore, as the security guide advises.

  1. Take a fresh dump (run the cron command by hand). The upgrade guide's first step is backing up the app DB. Note the running version from the startup log (docker logs metabase), then keep the current image under its own name: docker tag metabase/metabase:latest metabase/metabase:previous. A pull replaces latest, so without that tag nothing older is left to go back to.
  2. docker pull metabase/metabase:latest, then docker rm -f metabase. The upgrade guide recommends pulling a specific release tag rather than latest; if you pin one, note it from the IMAGE column of docker ps --filter name=metabase before upgrading and you have your rollback target already.
  3. Start it with the same docker run line. Metabase migrates the app DB schema on startup.
  4. To roll back: docker rm -f metabase, then recreate the app DB empty, because tables the newer migrations added would otherwise survive the restore: docker exec mb-postgres dropdb -U metabase metabaseappdb && docker exec mb-postgres createdb -U metabase metabaseappdb. Restore into it with cat /var/backups/metabase/app-2026-10-23.dump | docker exec -i mb-postgres pg_restore -U metabase -d metabaseappdb (your file's date), then start the same docker run line with metabase/metabase:previous (or the earlier pinned tag) as the image. Starting the new image on the restored dump would only migrate it forward again. migrate down, run with the newer version, is the fallback when changes made after the upgrade must be kept.
  5. From a release older than 40, step through each major version to the latest 40 first.

Licence: AGPL or commercial

Metabase's licence page states: "The Metabase Open Source Edition is offered under the AGPL." The image above, metabase/metabase, is that edition. Pro and Enterprise run a separate build, metabase/metabase-enterprise or the Enterprise JAR, and in the licence page's words: "Metabase Enterprise Edition binaries are released under our Metabase Commercial Software License." Their features stay off until a licence token is activated, under Admin > Settings > License or through an environment variable before start.

Embedding Metabase iframes in your own product has its own terms: the licence page lists abiding by the AGPL, the Embedding License (keeping the "Powered by Metabase" logo) or buying the commercial licence. Changing the code itself means abiding by the AGPL.

When this is the wrong setup

  • Nobody will own upgrades, dumps and key custody. Self-hosting means running all three.
  • You plan several Metabase nodes: they need a shared app DB, the same key pair on every node, and a scale-down to one node for each upgrade.
  • Your warehouse is very large and busy: give it its own machine and point Metabase at it with a read-only user.

Questions

How much RAM should a Metabase server have?

Metabase's docs give a heap rule rather than a user count: the JVM takes about a quarter of RAM by default, and you raise it with -Xmx while leaving 1 to 2 GB for other processes. On a box that also runs PostgreSQL, leave room for the app DB as well. Watch heap use over time; a sawtooth of constant garbage collection means it needs more.

Is the built-in H2 database good enough to start with?

For a local trial, yes; for anything people depend on, no. The docs call H2 the default for local demos and say to avoid it in production, recommending PostgreSQL. Moving later is possible with load-from-h2, but it must use the same Metabase version that last wrote the H2 file.

Docker image or JAR file?

The docs recommend Docker for self-hosting. The JAR needs Java 25 on the host and runs as a systemd service with an environment file. Both read the same MB_ variables, so the app DB setup is identical.

What does MB_ENCRYPTION_SECRET_KEY protect?

The connection details of your data sources stored in the app DB, encrypted with AES256 + SHA512. Anyone who copies the app DB without the key cannot read them. Lose or change the key and those connections must be re-entered, so back it up apart from the dumps.

Do I need a commercial licence to self-host Metabase?

Not for the Open Source edition, which is offered under the AGPL. Pro and Enterprise features need the Enterprise build and an activated licence token under the Metabase Commercial Software License. Embedding Metabase in your own application brings its own licence terms.

How do I get console access if the firewall locks me out?

Every racked single-, dual- and quad-socket server has an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), so a bad ufw rule is fixed from the console rather than a ticket. Among cartridges, the m510 and m710x carry a console as well; the rest offer power control alone.

Put Metabase on a machine of its own

Every line on the range page lists cores, memory, disks, port and cities; racked machines are handed over in about 30 minutes once payment is approved.

See the machines

Related on Server Room

Other use cases