Skip to content

Metabase BI dashboards

Template

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#

ParameterDescriptionDefault
key_nameSSH keypair name (must already exist)No default
flavor_nameInstance size (embedded H2 mode runs on 2 vCPU / 2 GiB)s1a.small
image_nameOperating system imageUbuntu-24.04
app_nameDisplay name prefix for resourcesmetabase
volume_sizeBlock volume size in GiB, mounted at /var/lib/docker20
external_networkExternal network for floating IP allocationPublicStatic
private_cidrCIDR for the private subnet10.40.0.0/24
editor_allowed_cidrCIDR allowed to reach the UI on port 300010.40.0.0/24
db_typeApp metadata store: embedded or postgresembedded
postgres_hostPostgreSQL host (when db_type is postgres)""
postgres_dbPostgreSQL database name (when db_type is postgres)metabase
postgres_userPostgreSQL 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 open http://localhost:3000.
  • Set editor_allowed_cidr to YOUR_IP/32 to 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. Set postgres_host, postgres_db, and postgres_user, then add MB_DB_PASS to /opt/metabase/.env on the instance and run docker 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.

Starting template$15.50/mo

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.

$16.50/mo

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

$16.50

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

$4.00

Public IP (included)

1 included with the custom package

$0.00

Package promotional discount

Flat −$5.00/mo on the custom package (same promotion as named plans).

$-5.00

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 more
$0.00

Private 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.

$0.00

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.

$0.00

Pricing data last validated: . For current rates, check quake.ai/pricing.

Template source#

7 files. Download the zip or expand to copy any file.Download metabase.zip
Show source (7 files)
main.tfHCL
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
}
variables.tfHCL
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"
}
outputs.tfHCL
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"
}
versions.tfHCL
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 2.0"
    }
  }
}

provider "openstack" {}
terraform.tfvars.exampleHCL
# 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-init/metabase.yaml.tftpl
#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
README.mdMarkdown
# 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
Provisions
Parameterized by
Variables
  • key_namerequired
  • 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"
  • editor_allowed_cidr="10.40.0.0/24"
  • db_type="embedded" validation {
  • postgres_host=""
  • postgres_db="metabase"
  • postgres_user="metabase"

Customize this pattern#

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

Was this page helpful?