Metabase is a great tool for easy dashboarding and analytics, it is open source meaning you can deploy it easily on your own infrastructure.

I currently host my metabase instance on scaleway serverless, but to save some money and for more flexibility, I’m going to move it to a 5$ hetzner server and deploy it with kamal, I’ll also set-up some basic backup of the metabase DB on an s3 bucket.

Kamal is great, it’s like the good old capistrano but for docker, and simpler. I like it because it’s only some commands over ssh on your server(s), to manage your images and their deployment. Besides some files on your server, it doesn’t even know it’s running kamal.

I’ll document the steps I used so you can follow along and perform a similar deployment, if you want to skip to the good stuff, here’s a gist with the whole config.


Let’s get started.

Set-up the server.

On your server, make sure you have docker and curl installed, that’s all kamal needs to manage your deployments.

Warning: the official metabase docker image does not work on arm64 architectures, we’ll assume your server has x86 arch.

Create a new repository

We’ll need some code to manage this deployment, so let’s create a repository to hold it.

A very short Dockerfile

Kamal can’t quite yet deploy public images on the fly. We still need to build and push an image of our own.

We’ll just piggy back on the official metabase image with the version we want.

FROM metabase/metabase:v0.48.1

A docker repository

Kamal works by building docker images, then pulling them on the server. In the meantime, they need to be stored in a docker repository.

You can create public repositories for free on hub.docker.com

A basic configuration file.

Let’s start with a very simple configuration file and we’ll iterate to achieve all the features we need.

For simplicity, we need this file to be in a new repository, and located at config/deploy.yml

service: metabase
image: your_username/metabase

servers:
  web:
    hosts:
      - 1.2.3.4

registry:
  username: your_username
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  clear:
    MB_DB_TYPE: postgres
    MB_JETTY_PORT: 3000
    JAVA_OPTS: -Xmx2g
  secret:
    - MB_DB_CONNECTION_URI

healthcheck:
  path: /api/health
  max_attempts: 20

accessories:
  pg:
    image: postgres:16.1-alpine
    host: 1.2.3.4
    port: 5432
    directories:
      - data:/var/lib/postgresql/data
    env:
      clear:
        POSTGRES_USER: metabase
        POSTGRES_DB: metabase
      secret:
        - POSTGRES_PASSWORD

Let’s break it down a bit.

Image and hosts

service: metabase
image: yourusername/metabase

servers:
  web:
    hosts:
      - 1.2.3.4

Nothing too fancy, here we are declaring our service name and the docker image we want to use for it (from our newly created repository). We are also declaring one web server, replace the IP with your own ;)

Registry options

registry:
  username: your_username
  password:
    - KAMAL_REGISTRY_PASSWORD

We need to provide kamal with our docker credentials, so it can pull our image. Your docker password will be set in the environment as a secret variable.

Environment

env:
  clear:
    MB_DB_TYPE: postgres
    MB_JETTY_PORT: 3000
    JAVA_OPTS: -Xmx2g
  secret:
    - MB_DB_CONNECTION_URI

Here we are setting some configuration variables needed by metabase. We want to use postgres for the metabase database, we also configure the web port to be 3000, which is expected by kamal to expose the web server.

I allocated 2g of ram for the JVM through the JAVA_OPTS, YMMV.

We also expose the MB_DB_CONNECTION_URI variable, this is the string that contains the connection URI to postgres, including the password, hence setting it as a secret variable. More on that later.

Health check

healthcheck:
  path: /api/health
  max_attempts: 20

Kamal checks for the health of the app before deploying it. Metabase provides a /api/health endpoint that will return 200 once it is fully booted, that’s exactly what we want !

we set max_attempts to 20 because metabase can take quite a while to boot up, especially if it has to run data migrations.

Accessories

accessories:
  pg:
    image: postgres:16.1-alpine
    host: 1.2.3.4
    port: 5432
    directories:
      - data:/var/lib/postgresql/data
    env:
      clear:
        POSTGRES_USER: metabase
        POSTGRES_DB: metabase
      secret:
        - POSTGRES_PASSWORD

Kamal allows us to have “accessories” living alongside our application, that are not build nor restarted along the app when it is deployed. It is a perfect use case for a database, a monitoring service, etc.

Here we set-up a the postgres database that our metabase instance will use.

We set a “directory”, so the PG data is persisted on our server disk.

We set some env vars to define the default user and db name. its password will be stored in the POSTGRES_PASSWORD secret env variable.

Fill your env

Speaking of, now is a great time to fill our secret env variables. Kamal will read them in the .env file :

POSTGRES_PASSWORD=hunter2
MB_DB_CONNECTION_URI=postgres://metabase:hunter2@1.2.3.4/metabase
KAMAL_REGISTRY_PASSWORD=your_docker_password_wow_scary

Make sure this file is not checked into your git repository !

Starting up our accessories and service

Kamal provides a nifty kamal setup command to perform all the preliminary tasks : push the env, create accessories, build and deploy our service.

kamal setup

If everything worked as supposed, at the end of the setup process, you should be greeted by the usual metabase welcome screen.

CleanShot 2024-01-02 at 14.41.17@2x.png

Optional : restore an existing DB

Skip this step if you want a fresh metabase instance.

I had an existing metabase instance, and I wanted to keep my queries, setup, etc.

First, I stopped the app to be sure nothing would be reading/writing on the db :

kamal app stop

Then, I re-created the pg accessory from scratch, effectively dropping the DB and creating a fresh one :

kamal accessory reboot pg

I then used pg_restore to restore a previously created dump, and redeployed the app with kamal deploy

Setting-up SSL

SSL config has always been daunting to me, and the kamal doc does not mention it at all yet. Thankfully, it is actually quite simple, thanks to this article written by Guillaume Briday.

Let’s run the following command to create the appropriate files on our server.

 mkdir -p /letsencrypt &&
  touch /letsencrypt/acme.json &&
  chmod 600 /letsencrypt/acme.json

Then, we need to add some config in our servers block :

servers:
  web:
    hosts:
      - 1.2.3.4
    labels:
      traefik.http.routers.metabase-web.entrypoints: websecure
      traefik.http.routers.metabase-web.rule: Host(`your.domain.tld`)
      traefik.http.routers.metabase-web.tls.certresolver: letsencrypt

Finally, we need to add some config for traefik, the web server that kamal uses to expose our application.

# Configure custom arguments for Traefik
traefik:
  options:
    publish:
      - "443:443"
    volume:
      - "/letsencrypt/acme.json:/letsencrypt/acme.json" # To save the configuration file.
  args:
    entryPoints.web.address: ":80"
    entryPoints.websecure.address: ":443"
    entryPoints.web.http.redirections.entryPoint.to: websecure # We want to force https
    entryPoints.web.http.redirections.entryPoint.scheme: https
    entryPoints.web.http.redirections.entrypoint.permanent: true
    certificatesResolvers.letsencrypt.acme.email: "your@email.com"
    certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json" # Must match the path in `volume`
    certificatesResolvers.letsencrypt.acme.httpchallenge: true
    certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web # Must match the role in `servers`

Let’s re-deploy our app and restart traefik to pick up the changes:

kamal deploy kamal traefik reboot

Your metabase instance should now be served over https.

Backing up your database to an S3-compatible remote bucket

Running stuff on your own servers is great, but it has its downsides.

Managing DB backups is one of them.

But with some ingenuity we can set it up pretty easily.

We can use postgres-backup-s3, a nifty docker container that will backup any PG db to any S3 bucket.

Sounds like a kamal accessory 🧐

Let’s add it :

accessories:
  pg:
    # previous PG configuration here ...
  s3_backup:
    image: eeshugerman/postgres-backup-s3:16
    host: 1.2.3.4
    env:
      clear:
        SCHEDULE: '@daily'
        BACKUP_KEEP_DAYS: 30
        S3_REGION: your-region
        S3_BUCKET: your bucket
        S3_PREFIX: backups
        POSTGRES_HOST: 1.2.3.4
        POSTGRES_DATABASE: metabase
        POSTGRES_USER: metabase
      secret:
        - POSTGRES_PASSWORD
        - S3_ACCESS_KEY_ID
        - S3_SECRET_ACCESS_KEY

Check out the README for more configuration options.

Don’t forget to add S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY to your .env file and push it with kamal env push

Then you can boot the accessory with kamal accessory boot s3_backup

To make sure everything worked, I started by omitting the SCHEDULE var, so that the backups perform instantly, then I re-set it and waited a day to make sure the cron worked as expected.

Troubleshooting

Kamal is essentially a wrapper around docker, so for troubleshooting, I used docker logs extensively, it can help figure out why a container is not working as expected. Coupled with docker ps to see what is going on, as well as the kamal log, you should fairly easily understand what is going on.

Conclusion

Kamal makes it easy to deploy anything anywhere ! I especially love the accessory concept which allow to deploy quite complex applications.

One downside I see for now, is that you are forced to build and push your own images, I initially tried to skip this part and deploy from the public image, but kamal really expects the image to have been built from itself (it looks up for specific container/image labels and tags).

Kamal is still quite new, I’m convinced these kinds of issues will be addressed fairly soon !


You can find the whole config on this gist