Teams often outgrow ad-hoc spreadsheet reporting when dashboards become business-critical for leadership, operations, and customer success. In that moment, the challenge is rarely "can we run Metabase," but rather "can we run it reliably, securely, and in a way that survives restarts, upgrades, and handoffs between engineers." This production guide shows a practical, repeatable deployment of Metabase on Ubuntu using Docker Compose, PostgreSQL metadata storage, and Nginx as a hardened TLS reverse proxy. The design emphasizes least privilege, durable backups, explicit health checks, and predictable operations so your analytics stack remains available during routine maintenance and high-traffic reporting windows.
Architecture and flow overview
This deployment separates concerns into three layers: (1) application runtime (Metabase container), (2) stateful metadata database (PostgreSQL), and (3) internet-facing proxy (Nginx with TLS). Users connect to Nginx over HTTPS, Nginx forwards traffic to Metabase on a private Docker network, and Metabase stores users, permissions, dashboards, pulses, and settings in PostgreSQL.
- Nginx: TLS termination, security headers, request buffering, and proxy timeouts.
- Metabase: dashboarding/UI service, connected to business data sources from the admin UI.
- PostgreSQL: durable metadata backend for application state.
- Docker volumes: persistent storage for PostgreSQL data and optional local backups.
Why this pattern works in production: it keeps Metabase stateless enough to upgrade safely, while protecting your core configuration and content in PostgreSQL with scheduled dump backups and explicit restore procedures.
Prerequisites
- Ubuntu 22.04/24.04 server with sudo access
- DNS record like
analytics.example.compointing to the host - Ports 80/443 open to the internet, SSH restricted
- At least 2 vCPU, 4 GB RAM, and 30+ GB storage (scale by dashboard/query load)
- A dedicated Linux service account for deployment operations
Security baseline before you start: enable unattended security updates, enforce key-based SSH auth, and restrict inbound ports using UFW or cloud firewall rules. Keep database credentials out of your shell history and committed files.
Step-by-step deployment
1) Install Docker Engine and Compose plugin
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-release
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
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
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
Manual copy fallback: If the copy button does not work in your browser/editor, select the full block and copy directly.
2) Create project layout and environment file
sudo mkdir -p /opt/metabase/{nginx,backups}
sudo chown -R $USER:$USER /opt/metabase
cd /opt/metabase
openssl rand -base64 32 | tr -d '\n' > .pg_password
openssl rand -base64 32 | tr -d '\n' > .mb_encryption_key
chmod 600 .pg_password .mb_encryption_key
Manual copy fallback: If the copy button is unavailable, copy the commands manually and verify file permissions with ls -l.
3) Create docker-compose.yml
services:
postgres:
image: postgres:16-alpine
container_name: mb-postgres
restart: unless-stopped
environment:
POSTGRES_DB: metabaseappdb
POSTGRES_USER: metabase
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
secrets:
- pg_password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U metabase -d metabaseappdb"]
interval: 10s
timeout: 5s
retries: 12
networks: [metanet]
metabase:
image: metabase/metabase:v0.50.24
container_name: metabase
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
MB_DB_TYPE: postgres
MB_DB_DBNAME: metabaseappdb
MB_DB_PORT: 5432
MB_DB_USER: metabase
MB_DB_PASS_FILE: /run/secrets/pg_password
MB_DB_HOST: postgres
MB_ENCRYPTION_SECRET_KEY_FILE: /run/secrets/mb_encryption_key
JAVA_TIMEZONE: UTC
MB_SITE_URL: https://analytics.example.com
secrets:
- pg_password
- mb_encryption_key
networks: [metanet]
secrets:
pg_password:
file: ./.pg_password
mb_encryption_key:
file: ./.mb_encryption_key
volumes:
pgdata:
networks:
metanet:
driver: bridge
Manual copy fallback: If button copy fails, copy the YAML manually and run docker compose config to validate syntax.
4) Create hardened Nginx reverse proxy config
sudo apt install -y nginx certbot python3-certbot-nginx
sudo tee /etc/nginx/sites-available/metabase.conf > /dev/null <<'EOF'
server {
listen 80;
server_name analytics.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
sudo ln -sf /etc/nginx/sites-available/metabase.conf /etc/nginx/sites-enabled/metabase.conf
sudo nginx -t
sudo systemctl reload nginx
Manual copy fallback: Use your terminal selection and paste flow if clipboard scripting is blocked.
5) Start application stack and bind Metabase locally
cd /opt/metabase
docker compose up -d
# optional: expose only loopback by publishing via socat or host firewall controls
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
Manual copy fallback: If copy control is unavailable, manually copy and confirm both containers are healthy.
6) Add TLS and security headers
sudo certbot --nginx -d analytics.example.com --non-interactive --agree-tos -m [email protected] --redirect
sudo tee /etc/nginx/snippets/metabase-security-headers.conf > /dev/null <<'EOF'
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "frame-ancestors 'self'; upgrade-insecure-requests" always;
EOF
sudo sed -i '/server_name analytics.example.com;/a\ include snippets/metabase-security-headers.conf;' /etc/nginx/sites-available/metabase.conf
sudo nginx -t && sudo systemctl reload nginx
Manual copy fallback: If JS copy handlers are stripped, copy line by line and re-run nginx -t.
7) Configure automated PostgreSQL metadata backups
cat > /opt/metabase/backup-metabase.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
TS=$(date +%Y%m%d-%H%M%S)
OUT=/opt/metabase/backups/metabase-${TS}.sql.gz
PASS=$(cat /opt/metabase/.pg_password)
docker exec -e PGPASSWORD="$PASS" mb-postgres pg_dump -U metabase -d metabaseappdb | gzip > "$OUT"
find /opt/metabase/backups -type f -name '*.sql.gz' -mtime +14 -delete
EOF
chmod +x /opt/metabase/backup-metabase.sh
( crontab -l 2>/dev/null; echo "17 2 * * * /opt/metabase/backup-metabase.sh" ) | crontab -
Manual copy fallback: Copy the script manually and test once before relying on cron.
Configuration and secrets handling best practices
Keep secret material in files mounted as Docker secrets rather than plain environment variables when possible. Restrict secret files to owner read/write only, and avoid placing any passwords in shell command history by reading from files at runtime. For high-trust environments, move secrets to a managed vault and render them into temporary files through a short-lived bootstrap step.
For Metabase app settings, set MB_SITE_URL to your canonical HTTPS domain to avoid broken links in notifications and embedded contexts. Use the admin panel to enforce SSO (SAML/OIDC) where possible and disable password auth for non-break-glass users. Define admin groups conservatively: one or two operators for platform settings, analysts for collections, and read-only groups for broad consumption.
On the database side, keep PostgreSQL private to the Docker network, disable public port publishing, and snapshot backups to off-host storage (S3-compatible bucket or encrypted object storage) at least daily. Validate restore monthly using a staging host; backup files are only useful when restore is practiced.
Verification checklist
# Container health
docker compose ps
# Proxy/TLS health
curl -I https://analytics.example.com
# Metabase health endpoint
curl -sS https://analytics.example.com/api/health
# Confirm cert renewal timer exists
systemctl list-timers | grep certbot
# Confirm backup file exists
find /opt/metabase/backups -maxdepth 1 -type f -name '*.sql.gz' | tail -n 3
Manual copy fallback: If needed, execute each command manually and capture output in your runbook.
Expected outcome: docker compose ps shows both containers running, curl -I returns HTTP 200/302 over HTTPS, health endpoint returns JSON with status ok, certbot timer is active, and fresh backup files appear on disk.
Common issues and fixes
Metabase keeps restarting
Usually caused by database connection failures or malformed compose environment values. Check container logs first, then verify MB_DB_* fields and whether PostgreSQL passed its healthcheck. If needed, run docker compose logs -f postgres metabase and confirm user/db names match exactly.
TLS works but app returns 502
Nginx cannot reach Metabase backend. Confirm Metabase container is up and listening, then validate proxy target (127.0.0.1:3000 in this guide). A quick fix path is sudo nginx -t, restart Nginx, and re-check container port mappings.
Slow dashboards during business hours
Start by inspecting query complexity and source database indexes. In Metabase, tune cache/metadata sync cadence and move expensive cards to pre-aggregated tables. If latency persists, scale source database resources and consider read replicas for analytics workloads.
Backups exist but restore fails
Most restore failures come from version mismatch or missing role/database prep. In staging, recreate empty DB and role first, then restore with matching major PostgreSQL version. Keep a one-page restore SOP with exact commands and expected outputs.
FAQ
Should I use H2 for Metabase in production?
No. H2 is fine for quick tests, but PostgreSQL is the recommended production metadata store for reliability, concurrency, and safer upgrades.
Can I run Metabase without Nginx?
You can, but a reverse proxy gives cleaner TLS, centralized security headers, and easier integration with WAF, rate limiting, and access controls.
What is the safest way to upgrade Metabase?
Take a metadata backup, pin a tested image tag, deploy to staging first, run smoke tests, then update production during a low-traffic window.
How often should metadata backups run?
At least daily for most teams; high-change environments should run backups every 4–6 hours with off-site replication and retention policies.
How do I enforce SSO for all users?
Configure your identity provider in Metabase admin settings, validate role mapping, and disable local password logins except a tightly controlled break-glass account.
What logs matter most during incident response?
Nginx access/error logs, Metabase container logs, and PostgreSQL logs. Correlate timestamps to identify whether failures are auth, proxy, or query-path issues.
Related guides
- Production Guide: Deploy Grafana Loki with Docker Compose + Caddy
- Production Guide: Deploy Apache Superset with Docker Compose + Nginx
- Production Guide: Deploy Keycloak with Docker Compose + Traefik
Talk to us
If you want support designing or hardening your analytics platform, we can help with architecture, migration planning, and production readiness.