The Problem: Your Team Needs Dashboards, but Not Another Data Leak Waiting to Happen
Most teams do not start by asking for “business intelligence software.” They ask for cleaner reporting. Sales wants a pipeline dashboard that updates without somebody rebuilding a spreadsheet every Friday. Operations wants to spot slow orders before customers complain. Leadership wants one place to see revenue, support volume, and delivery trends without exporting data into three different tools.
That is where the real decision starts. If the dashboards are built on customer records, financial data, or internal operational metrics, many organizations are not comfortable pushing all of that into another SaaS platform. Sometimes that is a cost concern. Sometimes it is a control concern. Often it is both.
Metabase is a strong fit when you want a reporting layer your team can use without turning every chart request into an engineering ticket. It is approachable, fast to stand up, and easier to adopt than heavier analytics platforms. In our experience, it is one of the best first self-hosted BI tools for internal dashboards.
In this guide, we will show you how to run Metabase with Docker and PostgreSQL on your own server, verify that it is healthy, and harden it for production use. If you are still deciding whether Metabase is the right tool in the first place, read our draft on self-hosted BI tools for Canadian companies first, then come back here once you are ready to build.
What You Will Need
For a small internal deployment, we recommend starting with a Canadian Web Hosting Cloud VPS in the VPS4 range: 4 GB RAM, 2 vCPUs, and at least 60 GB of SSD storage. That gives Docker, PostgreSQL, and Metabase enough room for a practical pilot without overbuilding on day one.
You will also need:
- An Ubuntu 24.04 server with Docker Engine and the Docker Compose plugin already installed
- A DNS record pointed at your server if you plan to expose Metabase over HTTPS
- A database source you want to analyze later, such as PostgreSQL, MySQL, MariaDB, or a warehouse
- A backup plan for both your Metabase application database and your source data
If you are still deciding between shared hosting, VPS, and dedicated infrastructure for internal business tools, our guide to Canadian SMB hosting costs across shared, VPS, and dedicated helps frame that decision clearly. If you do not want to manage Docker, patching, and reverse proxy configuration yourself, add Managed Support and let our team handle the operational side.
Installation Steps
1. Create a working directory and environment file
Start by giving this deployment its own directory. Keeping the compose file and secrets together makes it easier to back up, review, and move later if you need to rebuild the server.
mkdir -p ~/metabase
cd ~/metabase
mkdir -p postgres-data
cat > .env <<'EOF'
POSTGRES_DB=metabaseappdb
POSTGRES_USER=metabase
POSTGRES_PASSWORD=change-this-long-random-password
MB_DB_TYPE=postgres
MB_DB_DBNAME=metabaseappdb
MB_DB_PORT=5432
MB_DB_USER=metabase
MB_DB_PASS=change-this-long-random-password
MB_DB_HOST=postgres
EOF
What this does: it prepares the application database values that Metabase expects in production. Metabase documents these MB_DB_* environment variables for connecting to a production-ready application database.
Verify: run grep MB_DB_HOST .env and confirm it returns MB_DB_HOST=postgres.
Common mistake: using different passwords for PostgreSQL and Metabase in the same file. Keep them aligned here or the app will fail to start.
2. Create the Docker Compose stack
The official Metabase Docker documentation includes a Compose example with PostgreSQL and a health check. We are using that model here, with a couple of practical additions for restart behaviour and persistent storage.
cat > docker-compose.yml <<'EOF'
services:
postgres:
image: postgres:16
container_name: metabase-postgres
restart: unless-stopped
env_file:
- .env
volumes:
- ./postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
metabase:
image: metabase/metabase:latest
container_name: metabase
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- .env
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "curl", "--fail", "-I", "http://localhost:3000/api/health"]
interval: 15s
timeout: 5s
retries: 5
EOF
What this does: it runs PostgreSQL as the application database and Metabase as the dashboard service. The PostgreSQL volume persists metadata such as users, dashboards, collections, and saved questions.
Verify: run docker compose config. Expected result: Docker prints a fully rendered configuration with no YAML errors.
Common mistake: mapping Metabase to a different internal port. Metabase serves on 3000 in the container, so keep the right side of the mapping as 3000.
3. Start the containers and watch the first boot
Once the configuration is in place, bring the stack up and follow the logs during first startup. This is where you catch bad credentials, bad YAML, or a database that is not ready yet.
docker compose up -d
docker compose ps
docker logs -f metabase
What this does: it launches PostgreSQL first, waits for it to become healthy, then starts Metabase. The Metabase logs should eventually show that initialization is complete.
Verify: docker compose ps should show both containers in a running or healthy state. Then run:
curl -I http://127.0.0.1:3000/api/health
Expected output: an HTTP 200 response header. That confirms the same health endpoint used in the official Metabase example is reachable on your server.
Common mistake: opening the browser too early. If the logs are still showing initialization, give Metabase another minute before testing the setup URL.
4. Complete the first-run setup in the browser
Once the service is healthy, open http://your-server-ip:3000/setup in your browser. You will create the admin account, choose your site name, and optionally add your first data source. This is also where you decide who should have access and whether you want to invite other users now or later.
What this does: it writes the initial application configuration into PostgreSQL. This is why using the embedded H2 database is not a good production choice: if you throw away the container later, you do not want to lose your dashboards and metadata with it.
Verify: after setup, log in and create a test collection or a sample question. Refresh the page to confirm it persists.
Configuration for Production
A quick local install is easy. A useful internal service is about the operational details around it. The three big ones are URL handling, persistent application data, and database backups.
First, keep PostgreSQL as the application database. Metabase’s own Docker documentation is clear that the default embedded H2 database is not the right choice for production. Your application database stores everything your team builds inside Metabase: saved questions, dashboards, settings, collections, and permissions.
Second, put Metabase behind a reverse proxy and enable HTTPS. If you want the simplest route to automatic certificates, our guide to Caddy with automatic HTTPS is a good companion. A small Caddyfile is enough for many internal deployments:
metabase.example.ca {
reverse_proxy 127.0.0.1:3000
}
Third, treat PostgreSQL like production data. Back up postgres-data and test restore procedures, not just backup jobs. If this dashboard becomes important to the business, pair it with CloudSafe Backup and consider moving to Dedicated Servers if concurrency, retention, or isolation starts pushing beyond a simple pilot.
Verify That It Works
Before you invite anyone else in, verify the stack from the server side and from the application side.
docker compose ps
curl -I http://127.0.0.1:3000/api/health
docker exec -it metabase-postgres psql -U metabase -d metabaseappdb -c '\dt'
Expected results:
- The containers show as running
- The health endpoint returns HTTP 200
- PostgreSQL lists tables after Metabase completes initialization
Inside Metabase itself, add one real database connection and build one test dashboard. Make sure a non-admin user can log in and view only what they should. If reporting uptime matters to the business, tie the endpoint into one of the stacks in our self-hosted monitoring guide for small teams so you notice problems before the next Monday meeting.
Production Hardening
Firewall rules
Do not leave PostgreSQL exposed to the internet. If Metabase and PostgreSQL are on the same host, only expose the web tier.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 5432/tcp
sudo ufw enable
sudo ufw status
Expected output: 80 and 443 allowed, 5432 denied.
HTTPS and reverse proxy
Terminate TLS at Caddy or Nginx and keep Metabase listening locally on port 3000. That makes certificate renewal, logging, and access control easier to manage.
Backup strategy
At minimum, schedule PostgreSQL dumps and keep filesystem snapshots of the compose directory.
docker exec metabase-postgres pg_dump -U metabase metabaseappdb > metabaseappdb-$(date +%F).sql
Run a restore test on a non-production host before you trust the process.
Log rotation
If you keep long-lived container logs on the host, make sure they do not grow forever. Docker’s default json-file logging can be constrained in /etc/docker/daemon.json or by setting logging options per service.
Troubleshooting
Metabase container keeps restarting
Cause: the application database variables do not match the PostgreSQL container.
docker logs metabase | tail -n 50
docker compose exec postgres pg_isready -U metabase -d metabaseappdb
What you want to see: accepting connections from PostgreSQL. If you do not, fix the .env file and restart the stack with docker compose up -d.
The setup page never loads
Cause: the container is not healthy yet, port 3000 is blocked, or another service is already using that port.
docker compose ps
ss -tulpn | grep 3000
curl -I http://127.0.0.1:3000/api/health
What you want to see: Metabase listening on port 3000 and the health endpoint returning HTTP 200. If another process owns the port, change the published port and reload the proxy.
Dashboards disappear after recreating the container
Cause: Metabase was started without a production-ready application database or without persistent PostgreSQL storage.
docker inspect metabase-postgres | grep -A5 Mounts
ls -lah ~/metabase/postgres-data
What you want to see: a real mounted directory on the host and database files inside it. If the directory is empty, rebuild the stack with persistent storage before users rely on it.
Conclusion and Next Steps
Metabase is a practical way to give your team internal dashboards without handing another vendor all of your reporting data. The key production decision is not the dashboard UI. It is where the application state lives and whether the stack is easy to operate six months from now.
For most teams, a Cloud VPS is the right starting point. It gives you full root access, enough headroom for Docker and PostgreSQL, and a clean path to scale later. If the service becomes business-critical, move up to a higher VPS tier or a Dedicated Server, and add Managed Support if you want us to handle patching, monitoring, and operational maintenance.
If you want help choosing the right BI platform before you commit to a build, go back to our draft on self-hosted BI tools for Canadian companies. If you already know Metabase is the right fit, the stack above gives you a clean, supportable starting point for internal reporting on infrastructure you control.
Be First to Comment