10 August, 20245 minute read

Deploy Metabase on AWS App Runner

I’m working on a side project with a friend, and we needed to quickly spin up a BI tool to produce some dashboards of our data. I’ve used Metabase in past jobs and really like it, but the official tutorials for self-hosting it are a little out of date—the docs themselves say not to follow their instructions!

I didn’t really want to spend money on their cloud offering, and i also didn’t want to spend time provisioning servers and having to maintain them. We also already had a bunch of infrastructure up and running on AWS, so I wasn’t very interested in offerings from other cloud platforms.

Fortunately AWS released App Runner in 2021, which is conceptually very similar to GCP’s Cloud Run. You just point an App Runner service at a Docker image, configure some IAM roles, and out comes a working service.

Below is a runbook for you to follow if you are also interested in deploying Metabase on App Runner using Terraform or OpenTofu.

How to deploy Metabase

Create a database

Metabase needs a database to store its own application data inside. There are many ways to provision a database—Neon is great if you want a serverless PostgreSQL option—but I personally went with RDS. Regardless of which technology you choose, you will want to store a connection URL for your database inside either Parameter Store or Secrets Manager for later use.

I went with Parameter Store to save on the 40c/mo cost of a Secrets Manager secret.

The connection string should be JDBC-style, like so:

Click to copy
jdbc:postgresql://db.example.com:5432/mydb?user=dbuser&password=dbpassword

With all that said, here’s the Terraform I used to set up my database and create the SSM parameter:

Click to copy
resource "random_password" "metabase_db" {  length = 64} resource "aws_db_instance" "metabase" {  identifier          = "metabase"  deletion_protection = true   allocated_storage = 20  instance_class    = "db.t4g.micro"   engine         = "postgres"  engine_version = "16.3"  password       = random_password.metabase_db.result  username       = "metabase"   lifecycle {    prevent_destroy = true  }}

A quick note here on Terraform best practices: I really think it’s valuable to keep stateful resources separate from your stateless resources, and to set prevent_destroy = true. It can be nightmarishly difficult to recover from accidental deletion of a database, image repository, or similar after a terraform destroy inadvertently tears down such a resource.

Sometimes there’s a helpful deletion_protection attribute as in the case of EC2 or RDS, but this isn’t always guaranteed. Keep these resources inside distinct Terraform stacks and defensively set prevent_destroy = true—future you will thank you.

Set up an ECR repository

AppRunner can only run images from an ECR repository, which means it isn’t possible to directly pull the official metabase/metabase image. There are some people who have cloned and pushed a Metabase image to public ECR repositories (like this one), but personally I’m not a fan. I’d rather pull the official image straight from the source and bypass any middlemen.

To do this, we’ll need an ECR repository:

Click to copy
resource "aws_ecr_repository" "metabase" {  name                 = "vendor-metabase"  image_tag_mutability = "MUTABLE"   image_scanning_configuration {    scan_on_push = true  }   lifecycle {    prevent_destroy = true  }}

And then we’ll pull the official Metabase image and push it into our new repository:

Click to copy
locals {  metabase_ecr_tag = "${aws_ecr_repository.metabase.repository_url}:latest"} resource "terraform_data" "push_image" {  input = timestamp()   provisioner "local-exec" {    command = join(" && ", [      "docker pull metabase/metabase:latest",      "docker tag metabase/metabase:latest ${local.metabase_ecr_tag}",      "docker push ${local.metabase_ecr_tag}"    ])  }}

Good to know

The terraform_data resource was added in Terraform 1.4, as a replacement for the null_resource resource.

The timestamp() input here will ensure that every time you plan and apply the latest Metabase image gets pulled. This is helpful if you want to always want to run the latest version of Metabase. If you prefer to pin to a specific version of Metabase and be more intentional when upgrading, you can either remove the timestamp() input or specify a fixed image tag (e.g. metabase/metabase:v0.49.22.1)

Create IAM roles

AWS App Runner services can be assigned two different roles:

The principle of least privilege says we should scope these roles as minimally as possible. Let’s do that now.

Here’s an ECR access role which grants the minimum permissions required to pull our ECR image:

Click to copy
data "aws_iam_policy_document" "metabase_build_assume" {  statement {    actions = ["sts:AssumeRole"]    effect  = "Allow"     principals {      type        = "Service"      identifiers = ["build.apprunner.amazonaws.com"]    }  }} resource "aws_iam_role" "metabase_build" {  name               = "metabase-build"  assume_role_policy = data.aws_iam_policy_document.metabase_build_assume.json} data "aws_iam_policy_document" "metabase_build" {  statement {    actions = [      "ecr:GetDownloadUrlForLayer",      "ecr:BatchGetImage",      "ecr:DescribeImages",      "ecr:GetAuthorizationToken",      "ecr:BatchCheckLayerAvailability",    ]    effect    = "Allow"    resources = [aws_ecr_repository.metabase.arn]  }} resource "aws_iam_role_policy" "metabase_build" {  name = "metabase-build-policy"  role = aws_iam_role.metabase_build.name   policy = data.aws_iam_policy_document.metabase_build.json}

And here is an instance role which grants permissions for the SSM parameter I’ve stashed my database connection URI in. If you called your SSM parameter something other than metabase_db_connection_uri then you’ll need to update this code block, and if you decided to use Secrets Manager instead of Parameter Store then you’ll need to make changes for that, too.

Click to copy
data "aws_iam_policy_document" "metabase_instance_assume" {  statement {    actions = ["sts:AssumeRole"]    effect  = "Allow"     principals {      type        = "Service"      identifiers = ["tasks.apprunner.amazonaws.com"]    }  }} resource "aws_iam_role" "metabase_instance" {  name               = "metabase-instance"  assume_role_policy = data.aws_iam_policy_document.metabase_instance_assume.json} data "aws_iam_policy_document" "metabase_instance" {  statement {    actions   = ["ssm:GetParameters"]    effect    = "Allow"    resources = [      aws_ssm_parameter.metabase_db_connection_uri.arn,    ]  }} resource "aws_iam_role_policy" "metabase_instance" {  name = "metabase-instance-policy"  role = aws_iam_role.metabase_instance.name   policy = data.aws_iam_policy_document.metabase_instance.json}

With all this boilerplate out of the way, we are now ready to create our actual App Runner service.

Deploy an App Runner service

The aws_apprunner_service resource is what you use to define an App Runner service, and it’s unfortunately quite hefty. The resource requests defined below (1 vCPU and 2GB of RAM) appear to be the minimum you need in order to get through Metabase’s initial database setup. When I experimented with lower values I ran into issues. You might be able to drop these specifications after getting everything set up, but I didn’t bother.

Click to copy
resource "aws_apprunner_service" "metabase" {  service_name = "metabase"   health_check_configuration {    interval            = 15    path                = "/api/health"    timeout             = 5    unhealthy_threshold = 5  }   instance_configuration {    # This is the absolute bare minimum you need    cpu               = "1024"    instance_role_arn = aws_iam_role.metabase_instance.arn    memory            = "2048"  }   network_configuration {    ingress_configuration {      is_publicly_accessible = true    }  }   source_configuration {    image_repository {      image_configuration {        port = "3000"         runtime_environment_secrets = {          MB_DB_CONNECTION_URI = aws_ssm_parameter.metabase_db_connection_uri.arn        }      }       image_identifier      = local.metabase_ecr_tag      image_repository_type = "ECR"    }  }   auto_deployments_enabled = true} output "metabase_url" {  value = aws_apprunner_service.metabase.service_url}

The health check configuration comes straight from Metabase’s documentation; I just ported it from Docker compose YAML to what App Runner expects. Depending on your use case, you might also not want your Metabase instance exposed over the public internet.

You can swap is_publicly_accessible to false and wire up a VPC connector inside an egress_configuration block if you require more privacy. For my use case, I was happy to leave it public.

You’re done!

After running terraform apply you’ll see your metabase_url output in the terminal, in a format like <random-string>.<aws-region>.awsapprunner.com. You’ll need to wait a minute or two for Metabase to run all of its initial database migrations—you can keep track of that by watching the service’s logs in CloudWatch.

After a bit of waiting, you can open up https://<metabase_url>/setup in your browser and proceed through the setup wizard.

Afterwards, you’ll have a fresh Metabase instance ready to use, with no servers to think about 🎉

A screenshot of an empty Metabase instance running on App Runner

Caveats

App Runner’s autoscaling is completely based on the rate of incoming requests, just like GCP’s Cloud Run product. If your service—Metabase in this case—is not actively receiving traffic, then App Runner will throttle your CPU to save you cost.

AWS App Runner monitors the number of concurrent requests sent to your application and automatically adds additional instances based on request volume. If your application receives no incoming requests, App Runner will scale the containers down to a provisioned instance, a CPU-throttled instance ready to serve incoming requests within milliseconds.

AWS App Runner FAQs

In this CPU-throttled state, background workers will run extremely slowly. Metabase has many such background workers, which perform various tasks like fetching table metadata or refreshing your models. App Runner’s throttling might be problematic here, and the Metabase docs themselves call out scale-to-zero platforms as being risky.

So far I haven’t noticed any problems, but your mileage may vary—my Metabase instance is very small.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com