Metabase on a VPS: PostgreSQL Dashboards in One Evening
TL;DR
Metabase can be deployed on a VPS in one evening using Docker Compose, connected to PostgreSQL, and its dashboards made available over HTTPS. In this guide, we will install Metabase 0.57.x, PostgreSQL 17, and Caddy, configure a separate application database, a read-only user, backups, and basic server protection.
- A VPS with 2 vCPUs, 4 GB of RAM, and at least 40 GB of SSD storage is sufficient for a small team.
- Metabase will run in a Docker container, while PostgreSQL will store settings, users, and questions.
- The analytics data source will be available to Metabase through the internal Docker network.
- Caddy will automatically obtain a Let's Encrypt TLS certificate for the domain.
- Access to production data will be performed through a separate PostgreSQL user with read-only permissions.
- A daily backup of PostgreSQL and the configuration will be sent to external S3 storage.
1. TL;DR
This section already provides a brief plan: we host Metabase on a VPS, run it through Docker Compose, connect PostgreSQL, and publish the interface over HTTPS. Detailed commands and configuration files are provided below.
2. Contents
This article is intended for a VPS owner who wants to independently obtain a working business intelligence system without being required to depend on a cloud service. The example targets Ubuntu Server 24.04 LTS, the domain bi.example.com, and a clean server with a public IPv4 address.
The commands assume a connection to the server using a user with sudo privileges. If you already have a working PostgreSQL database, you can skip the section on creating a demonstration database, but the network and user configuration should still be checked.
3. What We Are Configuring and Why
What Is Metabase
Metabase is a web application for analyzing data and creating dashboards. It connects to PostgreSQL, MySQL, ClickHouse, and other sources, then allows you to build tables, charts, KPI cards, and filters without writing SQL for every query.
For a team, Metabase usually becomes an internal analytics portal: a manager looks at revenue, a marketer at the funnel, the support team at the number of requests, and a developer at technical metrics. Unlike BI systems built solely around SQL, Metabase offers a visual question builder while retaining a full-featured SQL editor.
What Will Work After Configuration
The final setup will consist of several containers. Metabase will handle the web interface and dashboards, PostgreSQL will handle Metabase's internal database, and a separate PostgreSQL container will be used as a demonstration data source. Caddy will receive HTTPS requests and forward them to Metabase.
| Component | Purpose | Port |
|---|---|---|
| Metabase 0.57.x | Web interface, queries, and dashboards | 3000 within the network |
| PostgreSQL 17 | Metabase metadata | 5432 within the network |
| PostgreSQL 17 | Demonstration analytics data | 5432 within the network |
| Caddy 2 | Reverse proxy and HTTPS | 80 and 443 |
Self-hosted or Cloud Metabase
The cloud option does not require maintaining the OS, Docker, TLS, or backups. It is suitable if starting data analysis quickly is more important than controlling the infrastructure. However, costs increase with the number of users, the scope of features, and corporate access requirements.
Self-hosted Metabase on a VPS provides control over data location, networking, versions, and costs. This option is useful if the company has a specialist capable of updating containers, checking backups, and responding to incidents. It is important to understand that a VPS does not turn the system into a fully managed service. Responsibility for security and recovery remains with the owner.
How Metabase Works with PostgreSQL
Metabase has its own application database. It stores users, access permissions, connection settings, saved questions, and collection structure. In production, you should not use the built-in H2 database: it is primarily intended for simple testing and migration.
The working database is connected separately. Metabase does not automatically copy all tables to the VPS; instead, it queries the source when a question or dashboard is opened. Therefore, bandwidth and network latency between Metabase and PostgreSQL affect visualization speed.
4. What VPS Configuration Is Needed for This Task
Resources depend less on Metabase itself than on the number of concurrent queries and PostgreSQL performance. Metabase can run on a small server if dashboards are opened by only a few people and queries use indexes. Large tables, dozens of users, and frequent chart updates will require more CPU and memory.
| Scenario | CPU | RAM | Disk | Network |
|---|---|---|---|---|
| Testing and personal project | 1–2 vCPUs | 2–4 GB | 30–40 GB SSD | 100 Mbps |
| Small team of up to 15 users | 2–4 vCPUs | 4–8 GB | 60–100 GB SSD | 100–500 Mbps |
| Several dozen users | 4–8 vCPUs | 8–16 GB | 100–250 GB NVMe | 500 Mbps and higher |
Practical Configuration
For the scenario described in this article, a reasonable starting point is 2 vCPUs, 4 GB of RAM, 60 GB of SSD or NVMe storage, one public IPv4 address, and a connection of at least 100 Mbps. The disk will contain Docker images, PostgreSQL, logs, and temporary files. If the data source is hosted on another server, most of the storage is needed not for tables, but for the operating system, backups, and cache.
You can choose a suitable VPS with these specifications or select an equivalent server from another provider. When choosing, check that incoming connections on ports 80 and 443 are allowed, Docker can be installed, and disk snapshots or additional storage can be used.
When a Dedicated Server Is Needed
A dedicated server is justified if PostgreSQL is already processing heavy analytical queries, the data occupies hundreds of gigabytes, guaranteed IOPS are required, or several services are planned to run on the same server. A dedicated server is also convenient for ETL, local backup storage, and large materialized views.
For ordinary Metabase usage, switching to a dedicated server does not by itself solve slow chart problems. First, you need to check SQL execution plans, indexes, aggregations, and caching. If a query takes two minutes because of a full table scan, additional cores will not always provide a noticeable improvement.
Choosing a Location
Location affects latency to PostgreSQL and users. If the database and Metabase are located in different regions, every interactive query incurs additional network latency. For small tables this is almost unnoticeable, but for many sequential queries it is already significant.
Place Metabase in the same region as the primary database or the application that supplies the data. For users in several countries, choose a region with acceptable latency or use a CDN only for static content, without trying to cache personalized Metabase responses.
5. Server Preparation
Creating a User and Updating Ubuntu
Ubuntu Server 24.04 LTS is assumed below. Connect via SSH using the user created by the provider and replace deploy with the desired name. Do not close the current SSH session until you have verified that you can log in as the new user.
# Обновляем индекс пакетов и устанавливаем исправления безопасности
sudo apt update && sudo apt full-upgrade -y
# Создаём отдельного администратора для повседневной работы
sudo adduser deploy
# Разрешаем пользователю выполнять административные команды
sudo usermod -aG sudo deploy
Copy the public SSH key from your local computer. The command does not transfer the private key to the server; it only adds the public key to the authorization file.
# Выполняем на локальном компьютере
ssh-copy-id deploy@SERVER_IP
# Проверяем вход новым пользователем
ssh deploy@SERVER_IP
Restricting SSH
After successfully verifying key-based login, disable password authentication and root login. Before changing the file, make sure the key actually works in a separate terminal window.
# Открываем конфигурацию SSH
sudo nano /etc/ssh/sshd_config.d/ hardening.conf
The command above contains an invalid space in the directory name. Use the correct version:
# Создаём отдельный файл настроек SSH
sudo nano /etc/ssh/sshd_config.d/hardening.conf
Insert the following parameters:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
X11Forwarding no
# Проверяем синтаксис конфигурации SSH
sudo sshd -t
# Применяем настройки без завершения текущих подключений
sudo systemctl reload ssh
Firewall and fail2ban
Open SSH, HTTP, and HTTPS. If SSH uses a non-standard port, specify it instead of 22. UFW should not block an already established SSH session, but it is still best to follow this order: first allow SSH, then enable the firewall.
# Устанавливаем базовые инструменты и защиту от перебора паролей
sudo apt install -y ca-certificates curl gnupg git jq unzip ufw fail2ban
# Разрешаем SSH и веб-трафик
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Включаем firewall
sudo ufw --force enable
# Проверяем активные правила
sudo ufw status verbose
Create a local fail2ban configuration. It will block addresses that repeatedly fail to log in to SSH.
# Создаём настройки jail для SSH
sudo tee /etc/fail2ban/jail.d/sshd.local > /dev/null <<'EOF'
[sshd]
enabled = true
backend = systemd
port = 22
maxretry = 5
findtime = 10m
bantime = 1h
EOF
# Запускаем fail2ban и добавляем его в автозагрузку
sudo systemctl enable --now fail2ban
# Проверяем состояние SSH-защиты
sudo fail2ban-client status sshd
Time Synchronization and Basic Parameters
Correct time is important for TLS, logs, and cron tasks. Ubuntu usually already uses systemd-timesyncd, so it is sufficient to check its status.
# Проверяем синхронизацию времени
timedatectl status
# Проверяем свободное место, память и загрузку
df -h
free -h
uptime
6. Software Installation — Step by Step
Installing Docker Engine and Compose
For production, use the official Docker repository rather than the outdated package from the standard Ubuntu repository. At the time of preparing this guide, the target stack is Docker Engine 27.x or a newer compatible release and Docker Compose v2.
# Создаём каталог для ключей APT
sudo install -m 0755 -d /etc/apt/keyrings
# Загружаем официальный ключ Docker
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
# Разрешаем чтение ключа пакетным менеджером
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Добавляем официальный репозиторий Docker для Ubuntu 24.04
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Устанавливаем Docker Engine, CLI и Compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Разрешаем пользователю deploy обращаться к Docker без sudo
sudo usermod -aG docker "$USER"
Log out of SSH and reconnect for the user's group membership to be updated.
# Проверяем версии Docker и Compose
docker version
docker compose version
# Проверяем запуск тестового контейнера
docker run --rm hello-world
Creating the Project Structure
All project files will be placed in /opt/metabase. Secrets will be stored in the .env file, which should not be added to Git or published in a web directory.
# Создаём каталоги проекта и хранения Caddy
sudo mkdir -p /opt/metabase/{postgres-init,analytics-init,caddy}
sudo chown -R "$USER":"$USER" /opt/metabase
cd /opt/metabase
# Создаём файл переменных окружения с закрытыми правами
touch .env
chmod 600 .env
Preparing Environment Variables
Generate random passwords. The Metabase application password must not match the analytics database user password. For production, keep a copy of the secrets in a password manager; otherwise, recovery after losing the VPS will be difficult.
# Генерируем три независимых случайных значения
openssl rand -base64 32
openssl rand -base64 32
openssl rand -base64 32
Create /opt/metabase/.env with the following contents. Replace all values after the equal sign.
POSTGRES_DB=metabase
POSTGRES_USER=metabase
POSTGRES_PASSWORD=CHANGE_ME_APP_DB_PASSWORD
ANALYTICS_DB=analytics
ANALYTICS_USER=analytics_reader
ANALYTICS_PASSWORD=CHANGE_ME_ANALYTICS_PASSWORD
MB_ENCRYPTION_SECRET_KEY=CHANGE_ME_LONG_RANDOM_SECRET
MB_SITE_URL=https://bi.example.com
TZ=UTC
The MB_ENCRYPTION_SECRET_KEY variable is used to encrypt sensitive values in the Metabase configuration. Do not change it after the initial startup without understanding the consequences: saved connection settings may become inaccessible.
Initializing the Demo Database
If you already have PostgreSQL, this step can be adapted to the existing schema. For standalone testing, create a small set of tables with orders, customers, and products.
# Создаём SQL-файл начальной схемы аналитической базы
cat > analytics-init/001-schema.sql <<'EOF'
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
country TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
amount NUMERIC(12,2) NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO customers (name, country)
SELECT
'Customer ' || n,
CASE n % 4
WHEN 0 THEN 'RU'
WHEN 1 THEN 'KZ'
WHEN 2 THEN 'DE'
ELSE 'PL'
END
FROM generate_series(1, 100) AS n;
INSERT INTO orders (customer_id, amount, status, created_at)
SELECT
(random() 99 + 1)::bigint,
round((random() 490 + 10)::numeric, 2),
CASE
WHEN n % 10 = 0 THEN 'cancelled'
ELSE 'paid'
END,
now() - ((random() 180)::int || ' days')::interval
FROM generate_series(1, 3000) AS n;
CREATE INDEX orders_created_at_idx ON orders (created_at);
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
CREATE INDEX orders_status_idx ON orders (status);
CREATE USER analytics_reader WITH PASSWORD 'CHANGE_ME_ANALYTICS_PASSWORD';
GRANT CONNECT ON DATABASE analytics TO analytics_reader;
GRANT USAGE ON SCHEMA public TO analytics_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO analytics_reader;
EOF
In the demo SQL, the password must match ANALYTICS_PASSWORD in the .env file. For a real project, it is better to create the user with a separate command or template the SQL so that the secret is not duplicated across multiple files.
Docker Compose
Create the docker-compose.yml file. Image versions are pinned with tags so that an unexpected update does not change system behavior. Before updating, first review the Metabase release notes and make a backup.
services:
metabase-db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: ${TZ}
volumes:
- metabase_db_data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
analytics-db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: ${ANALYTICS_DB}
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: ${TZ}
volumes:
- analytics_db_data:/var/lib/postgresql/data
- ./analytics-init:/docker-entrypoint-initdb.d:ro
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d ${ANALYTICS_DB}"]
interval: 10s
timeout: 5s
retries: 5
metabase:
image: metabase/metabase:v0.57.0
restart: unless-stopped
depends_on:
metabase-db:
condition: service_healthy
analytics-db:
condition: service_healthy
environment:
MB_DB_TYPE: postgres
MB_DB_DBNAME: ${POSTGRES_DB}
MB_DB_PORT: 5432
MB_DB_USER: ${POSTGRES_USER}
MB_DB_PASS: ${POSTGRES_PASSWORD}
MB_DB_HOST: metabase-db
MB_ENCRYPTION_SECRET_KEY: ${MB_ENCRYPTION_SECRET_KEY}
MB_SITE_URL: ${MB_SITE_URL}
JAVA_TIMEZONE: ${TZ}
expose:
- "3000"
networks:
- internal
caddy:
image: caddy:2.9
restart: unless-stopped
depends_on:
- metabase
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- internal
volumes:
metabase_db_data:
analytics_db_data:
caddy_data:
caddy_config:
networks:
internal:
driver: bridge
In Metabase 0.57.x, the current image may have a newer patch tag. Before starting, verify that the selected tag is available in the official registry. For production, it is important to pin a specific version rather than use latest.
7. Configuration, HTTPS, and Verification
Configuring Caddy
Before starting Caddy, create an A DNS record for bi.example.com pointing to the VPS public IP address. If IPv6 is used, ensure that the AAAA record also points to this server and that the firewall allows IPv6 traffic.
# Создаём конфигурацию reverse proxy
cat > caddy/Caddyfile <<'EOF'
bi.example.com {
encode gzip zstd
reverse_proxy metabase:3000
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /data/access.log
format json
}
}
EOF
# Проверяем, что домен резолвится в IP сервера
dig +short bi.example.com
If the dig utility is not installed, add the dnsutils package. The result must contain the address of your VPS specifically.
# Устанавливаем утилиту проверки DNS при необходимости
sudo apt install -y dnsutils
# Проверяем DNS-запись ещё раз
dig +short bi.example.com
Initial Startup
Before starting, check the Compose file and environment variables. Docker Compose will substitute values from .env. Errors in variable names often result in a container starting with an empty password or being unable to connect to PostgreSQL.
# Проверяем итоговую конфигурацию Compose
docker compose config
# Запускаем контейнеры в фоновом режиме
docker compose up -d
# Смотрим состояние всех сервисов
docker compose ps
# Смотрим последние сообщения Metabase
docker compose logs --tail=100 metabase
The first Metabase startup may take several minutes: the container applies application database migrations. Open https://bi.example.com in a browser. Caddy will request a Let's Encrypt certificate automatically if DNS has already propagated and ports 80/443 are accessible externally.
Initial Metabase Setup
In the first-run wizard, create an administrative account with a unique, long password. Do not use the VPS or PostgreSQL password. Specify the working time zone, currency, and organization name, then add a connection to the analytics database.
| Metabase Field | Value |
|---|---|
| Database type | PostgreSQL |
| Host | analytics-db |
| Port | 5432 |
| Database name | analytics |
| Username | analytics_reader |
| Password | The value of ANALYTICS_PASSWORD |
The name analytics-db is the DNS name of the service within the Docker network. Do not specify localhost: inside the Metabase container, it refers to the Metabase container itself, not PostgreSQL.
Verifying the PostgreSQL Connection
You can verify network connectivity and permissions from the Metabase container or a temporary PostgreSQL client. First, make sure the database responds.
# Проверяем состояние контейнеров
docker compose ps
# Проверяем доступность PostgreSQL из отдельного временного контейнера
docker run --rm --network metabase_internal \
-e PGPASSWORD="$ANALYTICS_PASSWORD" postgres:17 \
psql -h analytics-db -U analytics_reader -d analytics -c \
"SELECT count() AS orders_count FROM orders;"
The network name may differ if the project directory has a different name. Find the actual name with the docker network ls command. To test the application, use an HTTP request from the server itself.
# Проверяем локальный HTTPS-ответ Caddy
curl -I https://bi.example.com
# Проверяем срок и параметры сертификата
curl -vI https://bi.example.com 2>&1 | grep -E "SSL connection|subject:|expire date:"
# Проверяем логи Caddy при проблеме с TLS
docker compose logs --tail=100 caddy
Creating the First Dashboard
In the New section, create a question based on the orders table. For a simple revenue card, select the sum of the amount field with the status = paid filter. For a sales chart, group the sum by day using the created_at field. Then save the questions to a collection and combine them on a new dashboard.
For an SQL question, you can use the following query. It is better to add the date parameter through the Metabase interface so users can change the period without editing SQL.
SELECT
date_trunc('day', created_at)::date AS day,
SUM(amount) AS revenue,
COUNT() AS paid_orders
FROM orders
WHERE status = 'paid'
GROUP BY 1
ORDER BY 1;
Access Permissions
The administrator should create user groups and grant them access only to the required collections. Do not make all employees administrators: administrator permissions allow changing connections, users, and settings for the entire system.
If sensitive data is used, create separate PostgreSQL views instead of granting access to the source tables. For example, a view can hide email addresses, phone numbers, and internal identifiers while retaining only aggregated metrics. Also disable or restrict custom SQL queries if the audience should work only with prepared models.
8. Backups and Maintenance
What needs to be preserved
The main value of Metabase is not in the container, but in its application database. It contains users, collections, questions, models, connection settings, and permissions. Therefore, you should regularly back up the metabase database, not just the Docker image.
- A dump of the Metabase PostgreSQL application database.
- Data from the production analytics database, if it is located on the same VPS.
- The
.envfile in encrypted or protected storage. docker-compose.yml, Caddyfile, and SQL initialization scripts.- The Caddy
/dataand/configdirectories, if certificate state needs to be preserved. - Documentation on DNS, image versions, and the recovery procedure.
Installing restic
A backup on the same disk only protects against user error or database corruption. If the VPS is lost, it will disappear together with the source data. For production, use an external S3-compatible bucket, a separate server, or object storage in another region.
# Устанавливаем restic и PostgreSQL-клиент
sudo apt install -y restic postgresql-client
# Создаём закрытый файл с настройками удалённого хранилища
sudo nano /root/.restic-env
sudo chmod 600 /root/.restic-env
Example file:
export AWS_ACCESS_KEY_ID="CHANGE_ME"
export AWS_SECRET_ACCESS_KEY="CHANGE_ME"
export RESTIC_REPOSITORY="s3:https://s3.example.net/metabase-backups"
export RESTIC_PASSWORD="CHANGE_ME_RESTIC_PASSWORD"
Backup script
The script first creates dumps of both databases, then adds the configuration and sends everything to restic. Dumps are temporarily stored in a root-owned directory and deleted after completion. The external S3 bucket must support versioning and restricted key-based access.
# Создаём каталог для скрипта
sudo mkdir -p /usr/local/sbin /var/backups/metabase
sudo nano /usr/local/sbin/metabase-backup.sh
#!/usr/bin/env bash
set -Eeuo pipefail
PROJECT="/opt/metabase"
BACKUP_DIR="/var/backups/metabase"
STAMP="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
source /root/.restic-env
set -a
source "${PROJECT}/.env"
set +a
mkdir -p "${BACKUP_DIR}/${STAMP}"
docker compose -f "${PROJECT}/docker-compose.yml" exec -T metabase-db \
pg_dump -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
| gzip > "${BACKUP_DIR}/${STAMP}/metabase.sql.gz"
docker compose -f "${PROJECT}/docker-compose.yml" exec -T analytics-db \
pg_dump -U postgres -d "${ANALYTICS_DB}" \
| gzip > "${BACKUP_DIR}/${STAMP}/analytics.sql.gz"
cp "${PROJECT}/.env" "${BACKUP_DIR}/${STAMP}/.env"
cp "${PROJECT}/docker-compose.yml" "${BACKUP_DIR}/${STAMP}/docker-compose.yml"
cp "${PROJECT}/caddy/Caddyfile" "${BACKUP_DIR}/${STAMP}/Caddyfile"
restic backup "${BACKUP_DIR}/${STAMP}" --tag metabase
restic forget --tag metabase --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
rm -rf "${BACKUP_DIR:?}/${STAMP}"
# Делаем скрипт исполняемым
sudo chmod 700 /usr/local/sbin/metabase-backup.sh
# Инициализируем restic-репозиторий один раз
sudo bash -c 'source /root/.restic-env && restic init'
# Запускаем тестовый бэкап вручную
sudo /usr/local/sbin/metabase-backup.sh
# Проверяем список сохранённых снимков
sudo bash -c 'source /root/.restic-env && restic snapshots'
Please note that passing passwords through environment variables is convenient for an example, but they may be accessed by a process with sufficient privileges. Restrict access to root files and use an IAM key with permissions limited to the specific bucket.
cron scheduler
Run backups at night or during periods of low load. For small databases, a daily copy is sufficient, but critical data usually requires a shorter interval and separate PostgreSQL WAL archiving.
# Открываем системный crontab root
sudo crontab -e
Add the following line:
30 2 /usr/local/sbin/metabase-backup.sh >> /var/log/metabase-backup.log 2>&1
Recovery verification
A backup that has never been restored cannot be considered verified. At least once a month, start a temporary PostgreSQL instance, unpack the dump, and verify the presence of users, collections, and questions. For production data, separately check several key tables and checksums or row counts.
# Скачиваем последний снимок во временный каталог
sudo mkdir -p /tmp/metabase-restore
sudo bash -c 'source /root/.restic-env && restic restore latest --tag metabase --target /tmp/metabase-restore'
# Ищем восстановленные дампы
find /tmp/metabase-restore -type f -name '*.sql.gz' -ls
Updating Metabase
Do not automatically update production whenever a new Docker tag appears. First create a backup, read the changelog, and test the new version on a copy of the application database. For a small team, a 10–20 minute maintenance window is sufficient.
# Сохраняем текущие версии образов и состояние контейнеров
cd /opt/metabase
docker compose images
docker compose ps
# Создаём бэкап перед обновлением
sudo /usr/local/sbin/metabase-backup.sh
# Загружаем новые образы выбранных версий
docker compose pull
# Перезапускаем сервисы с обновлёнными образами
docker compose up -d
# Следим за миграциями Metabase
docker compose logs -f --tail=200 metabase
If the new version requires a database migration, rolling back the container without restoring the database may be unsafe. For important installations, first create a VPS snapshot or restore the dump into a separate test instance.
Resource monitoring
Monitor free disk space, memory, and query execution times. PostgreSQL and Docker logs can gradually fill the disk. Add Docker log rotation, especially if Caddy or Metabase are running with a verbose logging level.
# Проверяем потребление ресурсов контейнерами
docker stats --no-stream
# Проверяем размеры Docker-данных
docker system df
# Ищем каталоги, занимающие место
sudo du -xh /var/lib/docker /opt/metabase | sort -h | tail -n 20
9. Troubleshooting and FAQ
Why does a 502 Bad Gateway error appear?
First, check the status of the Metabase container with docker compose ps and the latest messages using docker compose logs --tail=200 metabase. If the container is restarting, the likely cause is an application database connection error, an incorrect password, or insufficient memory. If Metabase is running, verify that Caddy uses the name metabase, not localhost:3000. Restart Caddy after changing the Caddyfile.
Why is Caddy not obtaining a Let's Encrypt certificate?
Make sure the domain's DNS record already points to the public VPS IP address. Ports 80 and 443 must be allowed in UFW and in the provider's external firewall. If an incorrect AAAA record exists, Let's Encrypt may connect over IPv6 to another server. Check docker compose logs caddy, then run curl -I http://bi.example.com from an external computer. Do not use a domain inaccessible from the internet without a DNS challenge.
Metabase cannot connect to PostgreSQL with a “connection refused” error
In Docker Compose, specify the service name analytics-db and port 5432 in the Host field. Do not specify the container IP address: it may change after a restart. Check docker compose ps, the database healthcheck, and the logs with docker compose logs analytics-db. If the database was initialized earlier, changing the POSTGRES_PASSWORD variables does not automatically change the existing password. In this case, the password must be changed using an SQL command within PostgreSQL.
Why does the analytics_reader user receive permission denied?
Check permissions on the schema, existing tables, and default privileges. SQL from the analytics-init directory is executed only when an empty PostgreSQL volume is first created. If the volume already exists, a new SQL file is not started automatically. Apply permissions manually: GRANT USAGE ON SCHEMA public TO analytics_reader and GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_reader. For new tables, configure ALTER DEFAULT PRIVILEGES on behalf of the table owner.
Dashboards load slowly. What should I check?
Start with SQL queries rather than increasing the VPS size. Run the problematic query in PostgreSQL with EXPLAIN (ANALYZE, BUFFERS) and check whether indexes on dates, foreign keys, and filtering fields are being used. Reduce the data range, combine recurring queries, and create aggregated views. In Metabase, check whether too many cards are being run simultaneously. Then analyze CPU, RAM, IOPS, and network latency.
What is the minimum suitable VPS configuration?
For a test Metabase instance, 1–2 vCPUs, 2 GB RAM, and 30 GB SSD are sufficient, but such a server leaves almost no headroom for PostgreSQL, Docker, and background tasks. The practical minimum for a small team is 2 vCPUs, 4 GB RAM, and 40–60 GB SSD. If the analytics database, ETL, or regular heavy queries are on the same VPS, choose 4 vCPUs and 8 GB RAM. Use SSD rather than a slow HDD.
What should I choose for this task — VPS or dedicated?
A VPS is suitable for most small and medium Metabase installations: it is cheaper, scales quickly, and does not require managing a physical server. A dedicated server is needed for large PostgreSQL volumes, high IOPS requirements, constant ETL load, or the need to guarantee no neighbors on the physical host. Measure the actual load before migrating. Indexes and aggregated tables often provide a greater effect than additional CPU cores.
Can I expose Metabase directly on port 3000?
Technically, yes, but it is poor practice for production. Users will connect without proper TLS, and the application will be directly accessible from the internet. Use Caddy or another reverse proxy on ports 80/443, and keep port 3000 only inside the Docker network. Do not add ports: "3000:3000" to Compose. If such a port is already published, remove it and restart the containers.
What should I do if RAM runs out and the Metabase container restarts?
Check docker stats, free -h, and the system log for the OOM killer. First reduce the concurrency of heavy queries, optimize PostgreSQL, and stop unnecessary services. Temporary swap on an SSD can prevent a crash, but it does not replace physical memory. For sustained analytics workloads, increase the VPS to at least 8 GB RAM and configure monitoring to detect the problem before a failure occurs.
10. Conclusions and next steps
As a result, we have a self-hosted Metabase on a VPS with PostgreSQL 17, Docker Compose, HTTPS through Caddy, and a separate read-only database user. The configuration is suitable for a small team and allows dashboards to be created for orders, revenue, and other data without manually installing Java and a web server.
- Configure monitoring for CPU, RAM, disk space, PostgreSQL response time, and certificate expiration.
- As the load grows, move the analytics database to a separate server, add a read replica, or create aggregated tables.
- Regularly verify backup recovery and test Metabase updates on a copy of the application database.