Metabase is an open source business intelligence tool that lets you explore databases, build charts and share dashboards through a web interface, with a visual query builder for non-technical users and a SQL editor for everyone else. In this tutorial you will deploy Metabase on Ubuntu 24.04 with Docker Compose, store its own settings in PostgreSQL instead of the default embedded H2 file, publish it over HTTPS behind Nginx, connect a database with a read-only account and set up backups.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM (4 GB recommended for several concurrent users).
  • A non-root user with sudo privileges.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository.
  • A domain or subdomain (this guide uses bi.your_domain) with a DNS A record pointing to your_server_ip.
  • Ports 22, 80 and 443 open in your firewall.
  • A database you want to analyze (PostgreSQL, MySQL, MariaDB and others are supported) reachable from the server.

Step 1 - Preparing the project directory

Metabase needs its own database, called the application database, to store users, questions, dashboards and settings. Out of the box it uses an embedded H2 file, which the Metabase documentation does not recommend for production because it is easy to corrupt and hard to back up. This guide runs a PostgreSQL container for it instead.

Create the project directory:

sudo mkdir -p /opt/metabase
cd /opt/metabase

Generate a random password for the application database and store it in a .env file that Docker Compose reads automatically:

echo "MB_DB_PASS=$(openssl rand -hex 24)" | sudo tee /opt/metabase/.env > /dev/null
sudo chmod 600 /opt/metabase/.env

Confirm the file contains a single line with the password:

sudo cat /opt/metabase/.env
MB_DB_PASS=5f0c1e9a7b3d2c4e6f8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c

Step 2 - Writing the Compose file

Create the Compose file:

sudo nano /opt/metabase/compose.yaml

Add both services. Replace bi.your_domain in MB_SITE_URL with your domain:

services:
  metabase-db:
    image: postgres:17
    container_name: metabase-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: metabase
      POSTGRES_USER: metabase
      POSTGRES_PASSWORD: ${MB_DB_PASS}
    volumes:
      - metabase-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U metabase -d metabase"]
      interval: 10s
      timeout: 5s
      retries: 5

  metabase:
    image: metabase/metabase:latest
    container_name: metabase
    restart: unless-stopped
    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: ${MB_DB_PASS}
      MB_DB_HOST: metabase-db
      MB_SITE_URL: https://bi.your_domain
      JAVA_TIMEZONE: UTC
      JAVA_OPTS: "-Xmx1g"
    depends_on:
      metabase-db:
        condition: service_healthy

volumes:
  metabase-db-data:

What the important settings do:

  • MB_DB_* point Metabase at the PostgreSQL container instead of H2. The PostgreSQL port is not published, so only Metabase can reach it.
  • ports binds Metabase to 127.0.0.1. Ports published by Docker bypass UFW, so this is what keeps it off the internet until Nginx and TLS are in front.
  • MB_SITE_URL is the public address Metabase uses in links in emails and embeds.
  • JAVA_OPTS caps the Java heap. Around half of the server's RAM is a reasonable value; raise it to -Xmx2g on a 4 GB server.
  • depends_on with service_healthy makes Metabase wait until PostgreSQL accepts connections.

Validate the file:

sudo docker compose config --quiet && echo OK
OK

Step 3 - Starting Metabase

Start the stack:

sudo docker compose up -d

The first start takes one or two minutes while Metabase creates its tables in PostgreSQL. Follow the logs until initialization finishes, then press Ctrl+C:

sudo docker compose logs -f metabase
metabase  | ... Metabase Initialization COMPLETE

Query the health endpoint:

curl -s http://127.0.0.1:3000/api/health
{"status":"ok"}

Step 4 - Publishing Metabase with Nginx and HTTPS

Install Nginx and Certbot:

sudo apt update
sudo apt install nginx certbot python3-certbot-nginx

Create a server block:

sudo nano /etc/nginx/sites-available/metabase
server {
    listen 80;
    listen [::]:80;
    server_name bi.your_domain;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;
    }
}

proxy_read_timeout gives long-running queries time to finish instead of failing at the default 60 seconds.

Enable the site, allow web traffic and request a certificate:

sudo ln -s /etc/nginx/sites-available/metabase /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
sudo certbot --nginx -d bi.your_domain

Check the public endpoint:

curl -s https://bi.your_domain/api/health
{"status":"ok"}

Step 5 - Completing the setup wizard

Open https://bi.your_domain in your browser. The setup wizard asks for:

  1. Your preferred language.
  2. The administrator account: name, email and a strong password.
  3. What you will use Metabase for, and optionally your first data source. You can skip the data source here and add it in the next step.
  4. Whether to share anonymous usage data with Metabase.

After the wizard you land on the Metabase home page, which includes a sample database you can use to try the query builder.

Step 6 - Connecting a data source with a read-only user

Metabase only needs to read your data. Giving it a dedicated read-only account limits the damage if the Metabase credentials ever leak, and prevents a SQL question from modifying data by accident.

On the database server, create the account. For PostgreSQL, connect with sudo -u postgres psql and run the following, replacing shop with your database name and your_ro_password with a strong password:

CREATE ROLE metabase_ro WITH LOGIN PASSWORD 'your_ro_password';
GRANT CONNECT ON DATABASE shop TO metabase_ro;
\c shop
GRANT USAGE ON SCHEMA public TO metabase_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO metabase_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO metabase_ro;

The last statement grants read access to tables created later by the same owner, so new tables appear in Metabase without extra grants.

Make sure the database accepts connections from the Metabase server: PostgreSQL must listen on a reachable address (listen_addresses in postgresql.conf), pg_hba.conf must allow metabase_ro from your_server_ip, and the database server's firewall must allow port 5432 from that IP only.

Then add it in Metabase:

  1. Click the gear icon and open Admin settings, then Databases.
  2. Click Add database and choose PostgreSQL.
  3. Fill in a display name, the host, port 5432, the database name, metabase_ro and its password.
  4. Click Save.

Metabase tests the connection before saving and then scans the schema. After a minute the tables appear under Browse data.

Step 7 - Building a question and a dashboard

With the data source in place, create a first question with the query builder:

  1. Click New and choose Question.
  2. Pick a table from your database.
  3. Add a filter, for example a date range, and a summary such as Count of rows grouped by a date column by month.
  4. Click Visualize, choose a chart type and Save the question.

For SQL users, New > SQL query opens the native editor. Variables in double braces become filter widgets:

SELECT date_trunc('month', created_at) AS month,
       count(*) AS orders
FROM orders
WHERE created_at >= {{start_date}}
GROUP BY 1
ORDER BY 1;

After typing the query, set the variable type of start_date to Date in the side panel that opens.

To build a dashboard, click New > Dashboard, give it a name, add your saved questions and arrange the cards. Dashboard filters can be wired to several questions at once so one date picker controls every chart.

Control who sees what from Admin settings > People (users and groups) and Admin settings > Permissions (data access per group and collection access). Restrict the default All Users group and grant access through purpose-specific groups instead.

Step 8 - Backing up the application database

Everything you build in Metabase lives in the PostgreSQL container, so back it up. pg_dump produces a consistent dump while Metabase keeps running.

Create a backup script:

sudo nano /usr/local/bin/metabase-backup
#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/var/backups/metabase"
STAMP="$(date +%F)"

mkdir -p "$BACKUP_DIR"
docker exec metabase-db pg_dump -U metabase -d metabase \
  | gzip > "$BACKUP_DIR/metabase-$STAMP.sql.gz"

# Keep 14 days of backups
find "$BACKUP_DIR" -name 'metabase-*.sql.gz' -mtime +14 -delete

Make it executable, run it once and check the result:

sudo chmod 750 /usr/local/bin/metabase-backup
sudo /usr/local/bin/metabase-backup
sudo ls -lh /var/backups/metabase
-rw-r--r-- 1 root root 1.8M Sep 25 13:10 metabase-2026-09-25.sql.gz

Schedule it nightly:

echo '15 3 * * * root /usr/local/bin/metabase-backup' | sudo tee /etc/cron.d/metabase-backup

Copy /var/backups/metabase to off-site storage as part of your regular backup routine.

Step 9 - Upgrading Metabase

Metabase migrates the application database automatically when a new version starts, and migrations cannot be rolled back. Always take a backup first:

sudo /usr/local/bin/metabase-backup
cd /opt/metabase
sudo docker compose pull metabase
sudo docker compose up -d
sudo docker compose logs -f metabase

Wait for Metabase Initialization COMPLETE again before using it. If the upgrade fails, restore the previous image tag and the dump taken just before the upgrade.

Troubleshooting

Nginx returns 502 Bad Gateway right after a restart. Metabase is still starting. Watch sudo docker compose logs -f metabase and wait for the initialization message.

Metabase fails to start with a database connection error. Check that metabase-db is healthy with sudo docker compose ps, and that MB_DB_PASS in .env has not changed since the PostgreSQL volume was created. PostgreSQL only applies POSTGRES_PASSWORD when the volume is first initialized.

The container is killed or logs java.lang.OutOfMemoryError. Raise -Xmx in JAVA_OPTS, but keep it below the server's free memory, then run sudo docker compose up -d. Check free -h to see how much RAM is available.

Dashboards load slowly. The time is usually spent in the source database. Add indexes on the columns used by filters and groupings, and enable result caching for heavy questions in the admin settings.

Conclusion

Metabase is now running on Ubuntu 24.04 with Docker Compose, backed by a PostgreSQL application database, served over HTTPS and connected to your data through a read-only account, with nightly backups. From here you can configure SMTP under the admin settings to send dashboard subscriptions and alerts, organize your work into collections with per-group permissions, and turn frequently used queries into models that other users can build on.