Metabase BI dashboards
Metabase BI dashboards
This pattern composes Compute, Network, and Block Storage into a self-hosted business-intelligence host you run on infrastructure you control.
What this template does#
Provisions a single instance running Metabase, an open-source BI tool (a self-hosted alternative to Looker or Power BI). Analysts build dashboards and ask questions in SQL or a visual query builder:
- Compute instance that runs Metabase in Docker, sized for the embedded H2 app database (2 vCPU and 2 GiB RAM)
- Private network, subnet, router, port, and security group; a floating IP for public access
- A block volume mounted at
/var/lib/docker, so Metabase application data (saved questions, dashboards, users) lives on a volume you can grow rather than on the boot disk - cloud-init installs Docker Engine and starts Metabase from a compose file on first boot
Metabase connects to analytical warehouses you already run, such as self-managed PostgreSQL or ClickHouse. You add warehouse connections from the Metabase admin UI after setup.
No credential ships with this template. You create the admin account on first visit, and warehouse passwords stay in the Metabase UI or environment on the instance, not in tfvars.
Parameters#
| Parameter | Description | Default |
|---|---|---|
key_name | SSH keypair name (must already exist) | No default |
flavor_name | Instance size (embedded H2 mode runs on 2 vCPU / 2 GiB) | s1a.small |
image_name | Operating system image | Ubuntu-24.04 |
app_name | Display name prefix for resources | metabase |
volume_size | Block volume size in GiB, mounted at /var/lib/docker | 20 |
external_network | External network for floating IP allocation | PublicStatic |
private_cidr | CIDR for the private subnet | 10.40.0.0/24 |
editor_allowed_cidr | CIDR allowed to reach the UI on port 3000 | 10.40.0.0/24 |
db_type | App metadata store: embedded or postgres | embedded |
postgres_host | PostgreSQL host (when db_type is postgres) | "" |
postgres_db | PostgreSQL database name (when db_type is postgres) | metabase |
postgres_user | PostgreSQL user (when db_type is postgres) | metabase |
UI access and security#
The Metabase UI listens on port 3000 over plain HTTP. The security group restricts 3000 to editor_allowed_cidr, which defaults to the private network only, so the raw UI stays off the public internet. Reach it one of three ways:
- Put a reverse proxy (Caddy or Nginx) in front of Metabase and serve the UI over HTTPS on 443. Point the domain's DNS A record at the floating IP.
- Tunnel over SSH:
ssh -L 3000:localhost:3000 ubuntu@FLOATING_IP, then openhttp://localhost:3000. - Set
editor_allowed_cidrtoYOUR_IP/32to reach port 3000 directly from one address.
Ports 80 and 443 stay open for the reverse proxy you put in front; they carry no traffic until you add one.
Application database#
The db_type parameter selects where Metabase stores its own metadata (not your warehouse data):
embedded(default): H2 file database on the data volume. No external service runs, which suits getting started and small teams.postgres: points Metabase at an external PostgreSQL database for app metadata. Setpostgres_host,postgres_db, andpostgres_user, then addMB_DB_PASSto/opt/metabase/.envon the instance and rundocker compose up -d. The password stays out of tfvars and the repo.
When to use this pattern#
Run a self-service BI layer on a VM you operate. Metabase suits analyst dashboards, scheduled email reports, and SQL exploration against a warehouse you already host on Quake AI.
For a heavier BI stack with SQL Lab and richer charts, see Apache Superset. For the warehouse Metabase queries, see self-managed PostgreSQL.
Estimated cost#
Monthly cost estimate
Pricing calculator ↗Sized as a custom package on shared vCPU.
Monthly total for the required template above. Use the configurator below to add optional pieces and see the total update.
What each resource is for
Metabase host
s1a.small · 2 shared vCPU, 2 GiB RAM, 0.5 Gbps
Runs Metabase in Docker for self-service BI dashboards and SQL questions against your warehouse.
Metabase with the embedded H2 app database runs on 2 vCPU and 2 GiB RAM. Size up for many concurrent users or when you use an external Postgres app database.
Compute shown per role at custom-package rates ($29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM). The headline above is the billed total: the cheaper of a named plan and the custom package, plus add-ons.
Included in baseline
s1a.small
2 shared vCPU, 2 GiB RAM, 0.5 Gbps
Compute + RAM rate basis
2 vCPU + 2 GiB RAM at $29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM (regular). Totals apply the flat −$5/mo package promotion.
Block storage (50 GiB)
50 GiB at $0.08/GiB/mo
Public IP (included)
1 included with the custom package
Package promotional discount
Flat −$5.00/mo on the custom package (same promotion as named plans).
Included at no charge
These line items are zero on Quake AI. Many other providers meter them separately.
Data transfer (inbound and outbound)
Unlimited data transfer on every plan; Quake AI does not meter per-GB egress.
AWS, GCP, and Azure meter outbound transfer per GB. DigitalOcean and Hetzner include an allowance on compute plans, then charge overage.
Learn morePrivate networking
Private networks, subnets, Neutron routers, and security groups are included with the plan.
VPC objects are usually free to create elsewhere, but NAT gateways bill hourly plus per-GB processed. Quake AI uses router SNAT with no separate NAT line item.
Control-plane API requests
OpenStack API calls for provisioning and management are included.
Some managed services on other clouds meter API calls or charge for premium control-plane features.
Pricing data last validated: . For current rates, check quake.ai/pricing.
Template source#
Show source (7 files)Hide source
data "openstack_images_image_v2" "os" {
name = var.image_name
most_recent = true
}
data "openstack_networking_network_v2" "external" {
name = var.external_network
}
resource "openstack_networking_network_v2" "private" {
name = "${var.app_name}-net"
admin_state_up = true
}
resource "openstack_networking_subnet_v2" "private" {
name = "${var.app_name}-subnet"
network_id = openstack_networking_network_v2.private.id
cidr = var.private_cidr
ip_version = 4
dns_nameservers = ["1.1.1.1", "8.8.8.8"]
}
resource "openstack_networking_router_v2" "main" {
name = "${var.app_name}-router"
external_network_id = data.openstack_networking_network_v2.external.id
}
resource "openstack_networking_router_interface_v2" "private" {
router_id = openstack_networking_router_v2.main.id
subnet_id = openstack_networking_subnet_v2.private.id
}
resource "openstack_networking_secgroup_v2" "metabase" {
name = "${var.app_name}-sg"
description = "SSH and HTTP/HTTPS for a reverse proxy; Metabase UI port 3000 restricted"
}
resource "openstack_networking_secgroup_rule_v2" "ssh" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 22
port_range_max = 22
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.metabase.id
}
# 80 and 443 carry the editor when it is served over a domain with automatic
# TLS through a reverse proxy (Caddy or Nginx). They are not used until you put
# a proxy in front of Metabase; see the reference page.
resource "openstack_networking_secgroup_rule_v2" "http" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 80
port_range_max = 80
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.metabase.id
}
resource "openstack_networking_secgroup_rule_v2" "https" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 443
port_range_max = 443
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.metabase.id
}
# Raw Metabase UI HTTP on 3000 is restricted to editor_allowed_cidr (the private
# network by default). Prefer a domain with TLS on 443 for routine access.
# Metabase's setup wizard creates the admin account on first visit, but the port
# stays off the public internet by default.
resource "openstack_networking_secgroup_rule_v2" "editor" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 3000
port_range_max = 3000
remote_ip_prefix = var.editor_allowed_cidr
security_group_id = openstack_networking_secgroup_v2.metabase.id
}
resource "openstack_networking_port_v2" "metabase" {
name = "${var.app_name}-port"
network_id = openstack_networking_network_v2.private.id
security_group_ids = [openstack_networking_secgroup_v2.metabase.id]
fixed_ip {
subnet_id = openstack_networking_subnet_v2.private.id
}
depends_on = [openstack_networking_router_interface_v2.private]
}
resource "openstack_blockstorage_volume_v3" "data" {
name = "${var.app_name}-data"
size = var.volume_size
}
resource "openstack_compute_instance_v2" "metabase" {
name = var.app_name
flavor_name = var.flavor_name
key_pair = var.key_name
user_data = templatefile("${path.module}/cloud-init/metabase.yaml.tftpl", {
app_name = var.app_name
db_type = var.db_type
postgres_host = var.postgres_host
postgres_db = var.postgres_db
postgres_user = var.postgres_user
})
block_device {
uuid = data.openstack_images_image_v2.os.id
source_type = "image"
destination_type = "volume"
volume_size = 30
boot_index = 0
delete_on_termination = true
}
network {
port = openstack_networking_port_v2.metabase.id
}
}
resource "openstack_compute_volume_attach_v2" "data" {
instance_id = openstack_compute_instance_v2.metabase.id
volume_id = openstack_blockstorage_volume_v3.data.id
}
resource "openstack_networking_floatingip_v2" "metabase" {
pool = var.external_network
}
resource "openstack_networking_floatingip_associate_v2" "metabase" {
floating_ip = openstack_networking_floatingip_v2.metabase.address
port_id = openstack_networking_port_v2.metabase.id
}
variable "key_name" {
description = "SSH keypair name (must already exist in your project)"
type = string
}
variable "flavor_name" {
description = "Instance size. Metabase with the embedded H2 app database runs on 2 vCPU and 2 GiB RAM. Size up for many concurrent users or when you point Metabase at an external Postgres app database."
type = string
default = "s1a.small"
}
variable "image_name" {
description = "Operating system image. Ubuntu 24.04 is the recommended base."
type = string
default = "Ubuntu-24.04"
}
variable "app_name" {
description = "Display name prefix for compute and network resources"
type = string
default = "metabase"
}
variable "volume_size" {
description = "Block volume size in GiB, mounted at /var/lib/docker so Metabase application data lives on a volume you can grow rather than on the boot disk."
type = number
default = 20
}
variable "external_network" {
description = "Shared external network for router gateway and floating IPs; defaults to PublicStatic (persisted FIP / production pattern). Override with PublicEphemeral for ephemeral demos."
type = string
default = "PublicStatic"
}
variable "private_cidr" {
description = "CIDR for the private tenant network the instance lives in"
type = string
default = "10.40.0.0/24"
}
variable "editor_allowed_cidr" {
description = "CIDR allowed to reach the Metabase UI on port 3000. Defaults to the private network only. Reach it over an SSH tunnel, or serve it over HTTPS on 443 behind a reverse proxy. To allow direct access from your workstation, set this to YOUR_IP/32."
type = string
default = "10.40.0.0/24"
}
variable "db_type" {
description = "Metabase application database. 'embedded' (default) stores app metadata in H2 on the data volume. 'postgres' points Metabase at an external PostgreSQL database for app metadata; supply postgres_* and set MB_DB_PASS in /opt/metabase/.env on the instance (never in tfvars)."
type = string
default = "embedded"
validation {
condition = contains(["embedded", "postgres"], var.db_type)
error_message = "db_type must be either \"embedded\" or \"postgres\"."
}
}
variable "postgres_host" {
description = "PostgreSQL host for db_type = \"postgres\" (for example the private IP of a self-managed-postgres instance). Ignored when db_type is embedded."
type = string
default = ""
}
variable "postgres_db" {
description = "PostgreSQL database name for db_type = \"postgres\". Ignored when db_type is embedded."
type = string
default = "metabase"
}
variable "postgres_user" {
description = "PostgreSQL user for db_type = \"postgres\". The password is never set here: add MB_DB_PASS to /opt/metabase/.env on the instance and restart. Ignored when db_type is embedded."
type = string
default = "metabase"
}
output "instance_id" {
description = "ID of the compute instance running Metabase"
value = openstack_compute_instance_v2.metabase.id
}
output "floating_ip" {
description = "Public floating IP address of the Metabase host"
value = openstack_networking_floatingip_v2.metabase.address
}
output "private_ip" {
description = "Private IP address of the instance"
value = openstack_compute_instance_v2.metabase.access_ip_v4
}
output "ui_url" {
description = "Metabase UI URL on port 3000. Reachable from editor_allowed_cidr (the private network by default; tunnel over SSH, or put a reverse proxy in front and use HTTPS on 443)."
value = "http://${openstack_networking_floatingip_v2.metabase.address}:3000"
}
terraform {
required_version = ">= 1.6.0"
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "~> 2.0"
}
}
}
provider "openstack" {}
# Required: SSH keypair must already exist in your project
key_name = "YOUR_KEY_NAME"
# Recommended: restrict the UI (port 3000) to your workstation IP, or leave
# unset and tunnel over SSH, or put a reverse proxy in front on HTTPS.
# editor_allowed_cidr = "203.0.113.10/32"
# Application database: embedded (default, H2 on the data volume) or postgres
# (external Postgres for Metabase app metadata). Warehouse connections are
# configured in the Metabase UI after setup.
# db_type = "postgres"
# postgres_host = "10.50.0.12"
# postgres_db = "metabase"
# postgres_user = "metabase"
# flavor_name = "s1a.small"
# image_name = "Ubuntu-24.04"
# app_name = "metabase"
# volume_size = 20
# external_network = "PublicStatic"
# private_cidr = "10.40.0.0/24"
#cloud-config
package_update: true
packages:
- ca-certificates
- curl
write_files:
- path: /opt/metabase/docker-compose.yml
permissions: "0644"
content: |
services:
metabase:
image: metabase/metabase:latest
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- /opt/metabase/.env
volumes:
- metabase_data:/metabase-data
volumes:
metabase_data:
- path: /opt/metabase/.env
permissions: "0600"
content: |
# Metabase runtime configuration for ${app_name}.
# You create the admin account on first visit. Application metadata
# (questions, dashboards, users) lives on the metabase_data volume.
MB_JETTY_PORT=3000
JAVA_TIMEZONE=UTC
%{ if db_type == "postgres" ~}
MB_DB_TYPE=postgres
MB_DB_HOST=${postgres_host}
MB_DB_PORT=5432
MB_DB_DBNAME=${postgres_db}
MB_DB_USER=${postgres_user}
# Add MB_DB_PASS here on the instance and run `docker compose up -d`.
# Never commit the password to tfvars or the repo:
# MB_DB_PASS=
%{ else ~}
# Embedded H2 application database on the metabase_data volume.
MB_DB_FILE=/metabase-data/metabase.db
%{ endif ~}
runcmd:
- |
set -e
DEV=/dev/sdb
for i in $(seq 1 30); do [ -b "$DEV" ] && break; sleep 5; done
if ! blkid "$DEV" >/dev/null 2>&1; then mkfs.ext4 -F -L metabasedata "$DEV"; fi
mkdir -p /var/lib/docker
mount "$DEV" /var/lib/docker
grep -q "$DEV" /etc/fstab || echo "$DEV /var/lib/docker ext4 defaults,nofail 0 2" >> /etc/fstab
curl -fsSL https://get.docker.com | sh
cd /opt/metabase
docker compose up -d
# Metabase BI template
Single-instance OpenTofu template that provisions Metabase in Docker on a Quake AI compute instance with a block volume for application data.
**Network class:** production — `external_network` defaults to `PublicStatic` for persisted floating IPs and multi-tier stacks; override with `PublicEphemeral` for ephemeral demos.
## Validate locally
```bash
tofu init -backend=false
tofu validate
```
## Apply
Copy `terraform.tfvars.example` to `terraform.tfvars`, set `key_name`, then `tofu init && tofu apply`.
Connect analytical warehouses (for example [self-managed-postgres](../self-managed-postgres)) from the Metabase admin UI after first login.
Resources, parameters, and variables
key_namerequiredflavor_name="s1a.small"image_name="Ubuntu-24.04"app_name="metabase"volume_size=20external_network="PublicStatic"private_cidr="10.40.0.0/24"editor_allowed_cidr="10.40.0.0/24"db_type="embedded" validation {postgres_host=""postgres_db="metabase"postgres_user="metabase"
Customize this pattern#
- Customize a template's image and flavor
- Add a block volume to a template
- Parameterize a template with a tfvars file
See also#
Usage Guidelines
The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.
For the full policy, see Usage Guidelines.
Quick answers
- Why does `openstack image save` write a 0-byte file for my boot-from-volume instance?CLI
- Why does `openstack server create` fail with "Only volume-backed servers are allowed for flavors with zero disk"?CLIAPITerraform
- Why does my project still have a 10 GiB Cinder volume after I deleted my instance?CLIAPI
See Also
Terraform and OpenTofu on Quake AI
Prerequisite
Networks
Prerequisite
Authoring IaC templates for Quake AI
Shares: Volumes, Security Groups
Deploy an API gateway with the api-gateway template
Shares: Volumes, Security Groups
Deploy a regional edge cache with the edge-cache template
Shares: Volumes, Security Groups