GuidesBusiness and monitoringDashboards with Metabase

Run Metabase dashboards on your own data with rootless Podman

Metabase on Debian 13, the open-source tool for questions, charts and dashboards on your own data, in rootless Podman with PostgreSQL under a user of its own behind Caddy, reading your database through a login that cannot change anything.

Tested on Metabase 0.64.2.2 on Debian 13 (trixie) on a Melonslab server Updated October 14, 2026

Recommended server for this guide

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy · 2 vCPU · 8 GB Memory · 60 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€8.99/mo

Deploy now
On this page

What you will set up

Metabase turns the data in your database into answers, charts and dashboards. You build a question by picking a table, a sum and a grouping, without writing SQL, and Metabase draws the chart. Those who know SQL can write it directly. Questions go on dashboards that the whole team can open.

Metabase is developed by Metabase, Inc., a company in San Francisco, in the open on GitHub. The edition here, the open-source edition, is free under the AGPL 3.0 licence. Metabase also sells Pro and Enterprise editions, with features such as single sign-on with SAML and finer permissions, and Metabase Cloud, its hosted service. Out of the box, Metabase sends anonymous usage data to Metabase, checks for new versions with Metabase, and looks up the location of every login with GeoJS, an IP lookup service, by sending the visitor's IP address. Step 4 switches all three off; one page still uses GeoJS, as step 10 explains.

Metabase keeps its own data, such as users, questions and dashboards, in a database of its own. Without settings it uses a file in H2 format, which Metabase advises against for real use, so here it gets PostgreSQL. It reads your business data from your own database, through a login that can only read.

Here Metabase runs as a pod under a user of its own called metabase, behind Caddy from the Podman guide, and reads a database on PostgreSQL from the PostgreSQL guide on the same server.

Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:

  • Metabase 0.64.2.1 started with its own data in PostgreSQL 18, and the admin account was created over an SSH tunnel before Caddy was set up.
  • Metabase connected to a shop database on the server's PostgreSQL 18 through the read-only login. A question built without SQL drew revenue per month, a SQL question counted paid orders per city, and both went on a dashboard.
  • A DELETE in a SQL question failed with cannot execute DELETE in a read-only transaction. With the transaction switched to read-write, the login still got permission denied for table orders, and a query running longer than 60 seconds was stopped.
  • A second user, without the right to write SQL, could open the questions, and got an error when they tried SQL against the database.
  • Without email set up, adding a user gave a temporary password instead of an invitation. With email set up, invitations and password resets arrived, at a test mail catcher on the server.
  • The interface was shown in Swedish.
  • Logins were recorded with the visitor's real IPv4 address, and a forged X-Forwarded-For header was ignored.
  • A capture of the server's traffic showed no connections to Metabase or anyone else while Metabase started, restarted and was used, with one exception, described in step 10.
  • An update from Metabase 0.63 to 0.64 kept the questions and dashboards, and podman auto-update moved Metabase from 0.64.1 to 0.64.2.1. Metabase 0.64.2.2 later started on the same settings and read the shop database. Metabase was restored from a backup into an empty volume, and everything came back by itself after a reboot.

Metabase used about 1.7 GB of memory after the reboot, nearly all of it Java, and its own PostgreSQL about 30 MB. Java lets Metabase use up to a quarter of the server's memory, 2 GB on this server, so a 4 GB server is the smallest we would run it on together with PostgreSQL.

Before you start

You need:

  • a server set up as in the Podman guide, with Caddy running;
  • PostgreSQL on the same server, as in the PostgreSQL guide, with the database you want to look at;
  • an A record and an AAAA record for metabase.example.com pointing at your server;
  • SSH access to the server from your own computer.

The examples use metabase.example.com for Metabase, 203.0.113.10 for your server's IPv4 address, and appdb and appuser for the database and the app login from the PostgreSQL guide. Replace them throughout.

1. Create a read-only login

Metabase only needs to read your data. Give it a login of its own that cannot change anything, so that a mistake in a SQL question, or someone who gets into Metabase, cannot delete or change your data. As root, create the login with a long password, which createuser asks for twice:

openssl rand -base64 24
sudo -u postgres createuser --pwprompt metabase_ro

Then let it read the tables in appdb:

sudo -u postgres psql -d appdb <<'EOF'
ALTER ROLE metabase_ro SET default_transaction_read_only = on;
ALTER ROLE metabase_ro SET statement_timeout = '60s';
GRANT CONNECT ON DATABASE appdb TO metabase_ro;
GRANT USAGE ON SCHEMA public TO metabase_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO metabase_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE appuser IN SCHEMA public GRANT SELECT ON TABLES TO metabase_ro;
EOF

What the lines do:

  • GRANT SELECT lets the login read the tables that exist now, and nothing else. It cannot insert, change or delete rows, or create tables.
  • ALTER DEFAULT PRIVILEGES gives it the same right to the tables that appuser, the app's own login, creates later, such as after an update of the app.
  • default_transaction_read_only makes every transaction read-only as well, a second lock on top of the grants.
  • statement_timeout stops any query from Metabase after 60 seconds, so a heavy question cannot keep your app's database busy.

Keep the password for step 7. If your tables are in another schema than public, repeat the last three lines for it.

2. Create the user

As root:

useradd -m -s /bin/bash metabase
loginctl enable-linger metabase
machinectl shell metabase@

Everything up to step 6 runs as metabase.

3. Create the secrets

openssl rand -hex 24 | tr -d '\n' | podman secret create metabase-db-password -
openssl rand -hex 32 | tr -d '\n' | podman secret create metabase-encryption-key -

The first is the password of Metabase's own database. The second is the key Metabase encrypts the connection details of your databases with, such as the password of metabase_ro, before it stores them. Without the key, those details in a backup cannot be read, and you have to enter them again, so step 12 saves a copy.

4. Define the pod

mkdir -p ~/.config/containers/systemd
cd ~/.config/containers/systemd

Create metabase.pod:

[Pod]
PodName=metabase
# Only Caddy, on this server, can reach Metabase: the port is not open to the internet.
PublishPort=127.0.0.1:8131:3000
# Lets Metabase reach PostgreSQL on this server, which listens on 127.0.0.1, at 169.254.1.3.
Network=pasta:--map-host-loopback,169.254.1.3

[Install]
WantedBy=default.target

Create metabase-db.container:

[Container]
ContainerName=metabase-db
Image=docker.io/library/postgres:18-alpine
Pod=metabase.pod
Volume=metabase-db:/var/lib/postgresql
Environment=POSTGRES_USER=metabase POSTGRES_DB=metabase
Secret=metabase-db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U metabase -d metabase
HealthInterval=10s
Notify=healthy
AutoUpdate=registry

[Service]
Restart=always

And metabase-app.container:

[Unit]
Requires=metabase-db.service
After=metabase-db.service

[Container]
ContainerName=metabase-app
Image=docker.io/metabase/metabase:v0.64.x
Pod=metabase.pod
Environment=MB_SITE_URL=https://metabase.example.com
Environment=MB_DB_TYPE=postgres MB_DB_HOST=127.0.0.1 MB_DB_PORT=5432 MB_DB_DBNAME=metabase MB_DB_USER=metabase
Environment=MB_ANON_TRACKING_ENABLED=false MB_CHECK_FOR_UPDATES=false MB_SEND_EMAIL_ON_FIRST_LOGIN_FROM_NEW_DEVICE=false
Environment=JAVA_TIMEZONE=Europe/Stockholm
Secret=metabase-db-password,type=env,target=MB_DB_PASS
Secret=metabase-encryption-key,type=env,target=MB_ENCRYPTION_SECRET_KEY
AutoUpdate=registry

[Service]
Restart=always

What the settings do:

  • Network=pasta:--map-host-loopback,169.254.1.3 gives the pod an address, 169.254.1.3, that leads to 127.0.0.1 on the server. PostgreSQL from the PostgreSQL guide listens only there, and the pod's own 127.0.0.1 is the pod itself. Inside the pod, Metabase's own database answers on 127.0.0.1:5432, and yours on 169.254.1.3:5432. Every other program on the server that listens on 127.0.0.1 can be reached at that address too, such as the other apps' ports for Caddy, so whoever can add databases in Metabase, its admins, can try them.
  • MB_SITE_URL is the public address Metabase puts in links and emails.
  • MB_DB_* and the two secrets point Metabase at its own PostgreSQL in the pod, instead of an H2 file.
  • MB_ANON_TRACKING_ENABLED=false stops the anonymous usage data, and MB_CHECK_FOR_UPDATES=false the version check. Step 13 covers updates instead.
  • MB_SEND_EMAIL_ON_FIRST_LOGIN_FROM_NEW_DEVICE=false stops the location lookups with GeoJS. The same setting also stops the email Metabase otherwise sends a user who logs in from a new device; Metabase has no way to keep one without the other.
  • JAVA_TIMEZONE makes Metabase show dates and times in Swedish time.
  • v0.64.x follows every bug fix and security fix of Metabase 0.64, through podman-auto-update.timer. A new version, such as 0.65, changes Metabase's database, so you move to it by hand (step 13). PostgreSQL follows version 18 the same way.

Start it:

systemctl --user daemon-reload
systemctl --user start metabase-pod
systemctl --user enable --now podman-auto-update.timer

The first start downloads about 1.4 GB of images, and Metabase takes a minute or two to set up its database. Leave room for about 2.1 GB in all: at every start, Metabase's container also writes a 0.7 GB copy of its program file. It is ready when this prints {"status":"ok"}:

curl -s http://127.0.0.1:8131/api/health

5. Create the admin account over an SSH tunnel

Until an admin account exists, whoever opens Metabase first can create it and see all your data. Caddy does not show Metabase to anyone yet, so create the account through an SSH tunnel, which only you have.

On your own computer, open a tunnel to Metabase's port on the server, and keep it open:

ssh -L 8131:127.0.0.1:8131 root@203.0.113.10

Open http://localhost:8131 in your browser. It shows Welcome to Metabase. Choose Let's get started, and fill in First name, Last name, Email, Company or team name, and a password under Create a password and Confirm your password. Next creates the account straight away, so the rest of the questions are up to you:

  • Under What will you use Metabase for?, choose Self-service analytics for my own company and Next.
  • Under Add your data, choose Continue with sample data. You add your own database in step 7, once Metabase has its address.
  • Under Usage data preferences, switch off Allow Metabase to anonymously collect usage events, and choose Finish. The switch starts out on, but MB_ANON_TRACKING_ENABLED=false from step 4 keeps the collection off either way.

Close the tunnel when you are done.

6. Put Caddy in front

Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:

metabase.example.com {
    reverse_proxy 127.0.0.1:8131
}

Restart Caddy with systemctl --user restart caddy, and log in at https://metabase.example.com.

7. Connect your database

Open the menu at the top right, the square of dots, and choose Admin, then Databases > Add database. Database type is already PostgreSQL. Fill in:

  • Display name: the name your team sees, such as Shop;
  • Host: 169.254.1.3, the server's own 127.0.0.1 as seen from the pod (step 4);
  • Port: 5432;
  • Database name: appdb;
  • Username: metabase_ro, and its Password from step 1.

Choose Save. Metabase connects, reads which tables and columns there are, and shows Syncing completed. It reads them again every hour by default, so new tables show up by themselves. Metabase stores the password encrypted with the key from step 3.

Leave Model actions off. Actions let users change data through Metabase, which needs a login that can write.

Choose who may write SQL. By default, everyone in Metabase may write SQL against every database you add. They still only read what metabase_ro may read, but a SQL question can read every column of every table, including ones you would leave out of a dashboard, such as email addresses. To keep SQL to admins, open Permissions, choose the database, set All Users to Query builder only under Create queries, choose Save changes and confirm with Yes. Others can then build questions with the query builder and open saved questions. In our test, a SQL query from such a user was refused.

8. Questions and dashboards

A question is a query with its chart. To build one without SQL, choose New at the top right, then Question, your database and a table, such as Orders:

  1. Under Summarize, choose Pick a function or metric, then Sum of ... and a column, such as Total.
  2. Next to by, choose Pick a column to group by and a date, such as Created At. Metabase groups it by week; choose by week next to the column and then Month to change it.
  3. Choose Visualize. Metabase shows a line chart, and you can switch to another chart under Visualization.

Choose Save, give it a name, such as Revenue per month, and Save again.

For SQL, choose New > SQL query, pick the database at the top, write the query, and run it with Ctrl+Enter:

SELECT c.city, count(*) AS orders
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
GROUP BY c.city ORDER BY orders DESC

Save it the same way. A query that tries to change data gets an error from PostgreSQL, such as ERROR: cannot execute DELETE in a read-only transaction.

To gather questions in one place, choose New > Dashboard, give it a name and choose Create. Choose Add a chart, then each saved question, arrange the cards, and choose Save. A dashboard runs its questions every time someone opens it.

Metabase also offers ready-made overviews of a table, which it calls X-rays, on the home page. Its AI features, such as AI exploration and Metabot, need an account with an AI provider that you connect under Admin > AI; until you do, they do nothing. We did not test them.

9. Use Metabase in Swedish

Metabase is translated into Swedish. To make Swedish the default for everyone, choose Admin > Settings > Localization, and under Instance language choose Swedish. It takes effect straight away. Each user can choose their own under Language in their account settings, which open from their name in the same menu at the top right.

The names of your tables, columns and questions are not translated. Dates follow the language, while numbers keep a comma as the thousands separator until you change Separator style under Localization.

10. People

Choose Admin > People > Invite someone, fill in First name, Last name and Email, and choose Create. Without email (step 11), Metabase shows "We couldn’t send them an email invitation" with a temporary password. Pass it on to the person, who should change it after logging in.

New users join the All Users group, which may view everything in Our analytics, and use the databases as set under Permissions (step 7). Make someone an admin by adding them to Administrators.

Metabase records every login with the visitor's address, which each user sees under Login History in their account settings. Opening that page sends the listed addresses to GeoJS to look up a place for each one: this was the only outside connection in our capture. Single sign-on with Google or LDAP is under Admin > Settings > Authentication; SAML and JWT need a paid edition. We did not test them.

11. Email

Metabase sends email for invitations, password resets, dashboard subscriptions and alerts on questions. This guide does not set up email, and without it:

  • an invited user gets a temporary password on your screen instead of an email, as in step 10;
  • I seem to have forgotten my password on the login page says "Please contact an administrator to have them reset your password.", and an admin resets it under Admin > People;
  • subscriptions and alerts cannot be set up.

Logins with a password work as usual.

To send email, point Metabase at your email provider's SMTP server. As metabase, store the SMTP password as a secret:

printf %s 'the-smtp-password' | podman secret create metabase-mail-password -

Then add these lines to metabase-app.container, before AutoUpdate=registry, with your own server, account and sender address:

Environment=MB_EMAIL_SMTP_HOST=smtp.example.com MB_EMAIL_SMTP_PORT=587 MB_EMAIL_SMTP_SECURITY=starttls
Environment=MB_EMAIL_SMTP_USERNAME=metabase@example.com MB_EMAIL_FROM_ADDRESS=metabase@example.com MB_EMAIL_FROM_NAME=Metabase
Secret=metabase-mail-password,type=env,target=MB_EMAIL_SMTP_PASSWORD
systemctl --user daemon-reload
systemctl --user restart metabase-app

Port 587 uses STARTTLS; for port 465, which uses TLS from the start, set MB_EMAIL_SMTP_PORT=465 and MB_EMAIL_SMTP_SECURITY=ssl. Admin > Settings > Email then shows the settings as set by environment variables, and Send test email checks them. We tested these lines against a mail catcher on the server, not against a real provider: invitations and password resets arrived, with links to https://metabase.example.com. The emails show Metabase's logo from static.metabase.com and a font from Google Fonts, which the reader's mail program loads when it shows images.

12. Back up

As metabase:

umask 077
mkdir -p ~/backup
podman exec metabase-db pg_dump -U metabase metabase > ~/backup/metabase-db.sql
podman secret inspect --showsecret --format '{{.SecretData}}' metabase-encryption-key > ~/backup/encryption-key.txt

Metabase's database holds the users, questions, dashboards, permissions and settings, and the connection details of your databases, encrypted with the key. The dump runs while Metabase keeps working. Your business data is not in it: it stays in your own database, which the PostgreSQL guide backs up. Copy ~/backup to another machine, for example with restic, and keep it private.

To restore into an empty pod, as metabase, with the backup in ~/backup:

systemctl --user stop metabase-pod
podman volume rm metabase-db
systemctl --user start metabase-db
podman exec -i metabase-db psql -q -U metabase -d metabase < ~/backup/metabase-db.sql
systemctl --user start metabase-app

Starting only the database first loads the backup before Metabase sets up an empty one. On a new server, create the key from the backup instead of a new one in step 3:

tr -d '\n' < ~/backup/encryption-key.txt | podman secret create metabase-encryption-key -

13. Update

podman auto-update checks once a day for new images of Metabase 0.64 and PostgreSQL 18, and restarts the pod when one has changed. To update now:

podman auto-update

Each new version of Metabase, such as 0.65, changes its database when it first starts. Move to it by hand, after a backup (step 12): change v0.64.x to v0.65.x in metabase-app.container, then

systemctl --user daemon-reload
systemctl --user restart metabase-app

The first start downloads the new image and updates the database; podman logs metabase-app shows Database Migrations Current. Our update from 0.63 to 0.64 took 7 seconds for the database, and kept all questions and dashboards. Metabase cannot move its database back to an older version, so the backup from before the update is your way back, with the old tag. We did not test going back. Read the release notes on Metabase's GitHub before you update.

Troubleshooting

Adding the database fails with role "metabase_ro" does not exist. Host is 127.0.0.1 or localhost, which inside the pod is Metabase's own PostgreSQL. Use 169.254.1.3.

Adding the database fails with Connection refused, or times out. The Network= line in metabase.pod is missing, or the pod has not been restarted since you added it: systemctl --user daemon-reload and systemctl --user restart metabase-pod. Check that PostgreSQL runs on the server with pg_lsclusters.

password authentication failed for user "metabase_ro". The password is wrong. Set a new one as root with sudo -u postgres psql -c '\password metabase_ro', then use it under Edit connection details for the database in Admin > Databases.

A question fails with permission denied for table .... The table was created by another login than appuser, so step 1's default rights do not cover it. As root: sudo -u postgres psql -d appdb -c 'GRANT SELECT ON ALL TABLES IN SCHEMA public TO metabase_ro'.

Metabase says an invitation was sent, but nothing arrives. Metabase sends in the background, and keeps trying when the SMTP server cannot be reached. podman logs metabase-app 2>&1 | grep -i 'failed to send' shows the error.

https://metabase.example.com gives 502 for a minute after a restart. Metabase takes a minute or two to start. curl -s http://127.0.0.1:8131/api/health prints {"status":"ok"} when it is ready.

Run it on your own server

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy

€8.99/mo

vCPU
2
Memory
8 GB
Storage
60 GB
Transfer
10 TB
Performance
SSD · RAID 1
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides