See all articles

Run Metabase in production with Docker

To run Metabase in production with Docker, use the official metabase/metabase image with a pinned version tag such as v0.64.1, and keep the application database in PostgreSQL. Point Metabase at Postgres with the MB_DB_* environment variables or MB_DB_CONNECTION_URI, and pin a full version instead of latest, because Docker Hub has no stable tag. The open-source image is free to self-host with unlimited users, and the Pro and Enterprise plans run the metabase/metabase-enterprise image with a license token.

This page follows the Metabase docs for version 64 and was checked on 2026-10-07. It covers image tags, a docker compose file with Postgres, environment variables, the encryption key, the health check, Java memory, backups and upgrades. Short notes at the end cover AWS ECS Fargate, Google Cloud Run, Fly.io and Render.

Which Metabase Docker image tag should you use?

Metabase versions follow the pattern License.Major.Minor.Patch, where the first number is 0 for the open-source edition and 1 for the Enterprise Edition. So v0.64.1 is the first stable release of Metabase 64 in the open-source image, and v1.64.1 is the same release in the metabase/metabase-enterprise image. Metabase also publishes patch releases with a fourth number, such as v0.63.19.3.

Docker Hub also carries moving tags: v0.64.x follows the newest build of Metabase 64, and v0.64.1.x follows the newest patch of 64.1. latest is a moving tag. Metabase moves it to a new major version some time after that version ships, so pin an exact version and upgrade on purpose. There is no stable tag either, so a full release tag such as v0.64.1 is how you pin a stable version. The Metabase upgrade guide recommends a specific tag over latest, and the version support page doesn’t recommend moving .x tags in production.

To find the current release, ask the GitHub releases API:

curl -s https://api.github.com/repos/metabase/metabase/releases/latest | jq -r .tag_name

Metabase 64 is a long-term support (LTS) release, supported until December 1, 2027, so pin a Metabase 64 release if you want fewer major upgrades. Docker Hub also has a v0.64-lts tag that follows the newest Metabase 64 build. Metabase 58 is the previous LTS release, supported until February 17, 2027. LTS versions get 14 months of support, which is longer than other versions get. Metabase 55 and 56 reached end of life on May 31, 2026.

A production docker compose file with a Postgres application database

The application database is where Metabase keeps its users, questions, dashboards and settings. The default H2 database is a file inside the container, so you lose it when the container goes away, and Metabase recommends PostgreSQL for production. This compose file builds on the example from the Running Metabase on Docker page. It adds a pinned tag, a Docker secret for the password, an encryption key, a memory limit and a start period for the health check. It also publishes port 3000 on 127.0.0.1 only, so only the Docker host can reach Metabase’s plain HTTP port. A reverse proxy with TLS on the same host then serves Metabase to your users. If the proxy runs on another machine, publish the port on the host’s private address instead.

# docker-compose.yml
services:
  metabase:
    # Pin a full version. Replace it with the current release from
    # https://github.com/metabase/metabase/releases
    image: metabase/metabase:v0.64.1
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000" # only this host can connect; put a reverse proxy with TLS in front
    environment:
      MB_DB_TYPE: postgres
      MB_DB_DBNAME: metabaseappdb
      MB_DB_PORT: 5432
      MB_DB_USER: metabase
      MB_DB_PASS_FILE: /run/secrets/db_password
      MB_DB_HOST: postgres
      MB_ENCRYPTION_SECRET_KEY: ${MB_ENCRYPTION_SECRET_KEY:?set MB_ENCRYPTION_SECRET_KEY in .env}
      MB_SITE_URL: https://metabase.example.com
      JAVA_OPTS: -Xmx2g
      JAVA_TIMEZONE: UTC # match the time zone of your reports
    secrets:
      - db_password
    mem_limit: 3g
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: curl --fail -I http://localhost:3000/api/health || exit 1
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 5m

  postgres:
    image: postgres:17 # with postgres:18 and later, mount the volume at /var/lib/postgresql
    restart: unless-stopped
    environment:
      POSTGRES_USER: metabase
      POSTGRES_DB: metabaseappdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U metabase -d metabaseappdb"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pg_data:

secrets:
  db_password:
    file: ./db_password.txt

Create the key file and the password file next to it, then start the stack:

echo "MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)" > .env
openssl rand -base64 24 > db_password.txt
docker compose up -d
docker compose logs -f metabase

Metabase creates its own tables on the first start, but it doesn’t create the Postgres database itself, so here the postgres image creates it from POSTGRES_DB. Each Metabase environment, such as staging or production, needs its own application database. For the databases you analyze, Metabase recommends a dedicated read-only database user. Databases with official drivers, such as ClickHouse and PostgreSQL, work with the image as it is. Mount a /plugins directory only for community drivers or extra JDBC files, such as Oracle’s.

Environment variables for the application database

Metabase reads the application database settings when it starts. Set them one by one with MB_DB_TYPE, MB_DB_DBNAME, MB_DB_PORT, MB_DB_USER, MB_DB_PASS and MB_DB_HOST, as the compose file does, or put them in one JDBC URL with MB_DB_CONNECTION_URI:

MB_DB_CONNECTION_URI="jdbc:postgresql://db.example.com:5432/metabaseappdb?user=metabase&password=your_password"

With MB_DB_CONNECTION_URI, Metabase reads the database type from the URL, and it also accepts the postgres://user:password@host:5432/database form that hosts such as Heroku hand out. Metabase doesn’t URL-decode a password written as user:password@, so if the password has special characters, pass it in MB_DB_PASS and leave it out of the URL. To check the server’s certificate, add sslmode=verify-ca&sslrootcert=... to the URL. The application database must use the public schema.

On Docker, MB_DB_USER, MB_DB_PASS, MB_DB_CONNECTION_URI, MB_EMAIL_SMTP_USERNAME, MB_EMAIL_SMTP_PASSWORD, MB_LDAP_PASSWORD and MB_LDAP_BIND_DN also take a _FILE suffix that points to a Docker secret, as MB_DB_PASS_FILE does above. Set MB_SITE_URL to the address people use, because Metabase builds the links in its emails from it. The JAVA_TIMEZONE variable sets the Java time zone, and the Metabase docs suggest the time zone you want your reports in.

Encrypt connection details with MB_ENCRYPTION_SECRET_KEY

MB_ENCRYPTION_SECRET_KEY makes Metabase encrypt the connection details of your databases inside the application database. Use a key of at least 16 characters, such as the output of openssl rand -base64 32, and set it before the first start. If you add a key to a Metabase that already holds data, run the enable-encryption command once. Metabase won’t start while the key is set and the data isn’t encrypted with it:

docker compose stop metabase
docker compose run --rm metabase enable-encryption
docker compose up -d metabase

Store the key somewhere safe, such as a password manager or a secrets manager, and keep it separate from the application database and its backups. You still need the key to use a backup. Without it, nobody can decrypt the backup’s connection details, so you would have to enter them again for every database. To change the key later, use the rotate-encryption-key command with the new key.

Health check: /api/health, /livez and /readyz

GET /api/health returns 200 with {"status":"ok"} once Metabase is ready and can reach its application database, and it returns a 503 while Metabase starts. The compose file checks it with curl, which the image includes, and start_period gives Metabase time to run its migrations, because failed checks in that window don’t count. On platforms with separate probes, use GET /livez for liveness and GET /readyz for readiness and startup. A liveness check on /api/health alone can restart Metabase over and over while the application database is down, and /livez avoids that because it doesn’t check the database.

Java memory: JAVA_OPTS and -Xmx

Metabase needs at least 1 CPU core and 1 GB of RAM, plus 1 CPU and 2 GB for every 20 people using it at the same time. Set the Java heap with JAVA_OPTS, for example -Xmx2g, and keep it below the container’s memory limit so the JVM has room for memory outside the heap. The compose file gives the container 3 GB and the heap 2 GB, and in its own load test Metabase set the heap to 80% of the RAM.

Back up the application database

Back up the application database every day and before every upgrade. With the compose file above, pg_dump runs inside the Postgres container:

docker compose exec -T postgres pg_dump -U metabase -Fc metabaseappdb > "metabase-$(date +%F).dump"

To restore a backup, stop Metabase, load the dump with pg_restore, and start Metabase again:

docker compose stop metabase
docker compose exec -T postgres pg_restore -U metabase -d postgres --clean --if-exists --create < metabase-2026-10-07.dump
docker compose start metabase

With --create, pg_restore connects to the postgres database, drops metabaseappdb and recreates it from the archive, so tables that a newer Metabase version added don’t survive the restore. PostgreSQL can’t drop a database while someone else is connected to it, so stop Metabase first. The same command works for a rollback after an upgrade.

Copy the dumps off the host. On Amazon RDS, turn on automated backups instead.

Upgrade Metabase and roll back

To upgrade, take a backup, change the image tag, and pull and start the new image. Metabase runs its database migrations when it starts, so wait for /api/health to return ok before you check the version:

# After the backup, set the new tag in docker-compose.yml.
docker compose pull metabase
docker compose up -d metabase
docker compose logs -f metabase
curl -s http://localhost:3000/api/session/properties | jq -r '.version.tag'

A minor upgrade, such as 63.18 to 63.19, rarely changes the application database. A major upgrade, such as 63 to 64, runs schema migrations, so plan for some downtime and move to the latest minor release of the new major version. If you run more than one Metabase container, scale down to one before a major upgrade, and make sure your platform doesn’t stop Metabase while the migrations run.

To roll back, Metabase recommends that you restore the backup you took before the upgrade and start the old tag. If you need to keep changes made after the upgrade, run migrate down with the newer image first, then start the old tag:

docker compose stop metabase
docker compose run --rm metabase "migrate down"   # run this while docker-compose.yml still has the new tag
# Then set the old tag in docker-compose.yml.
docker compose up -d metabase

Run the same image on AWS ECS Fargate, Google Cloud Run, Fly.io or Render

The same image and environment variables work on container platforms. Metabase runs scheduled work such as syncs, dashboard subscriptions and alerts inside the server, so keep one instance running and turn off settings that stop idle instances.

AWS ECS Fargate. Put the application database on Amazon RDS for PostgreSQL and turn on automated RDS backups. Fargate tasks use the awsvpc network mode, so you map only containerPort 3000. A task with 1 vCPU can take 2 to 8 GB of memory, and 4 GB fits the -Xmx3g heap below. Pass the password and the encryption key through secrets, whose valueFrom points to Secrets Manager or Parameter Store, and give the task execution role permission to read them. The container health check takes a startPeriod of up to 300 seconds.

{
  "name": "metabase",
  "image": "metabase/metabase:v0.64.1",
  "portMappings": [{ "containerPort": 3000 }],
  "environment": [
    { "name": "MB_DB_TYPE", "value": "postgres" },
    { "name": "MB_DB_HOST", "value": "metabase-app.abc123xyz.us-east-1.rds.amazonaws.com" },
    { "name": "MB_DB_PORT", "value": "5432" },
    { "name": "MB_DB_DBNAME", "value": "metabaseappdb" },
    { "name": "MB_DB_USER", "value": "metabase" },
    { "name": "JAVA_OPTS", "value": "-Xmx3g" }
  ],
  "secrets": [
    { "name": "MB_DB_PASS", "valueFrom": "arn:aws:secretsmanager:us-east-1:111122223333:secret:metabase-db-password-AbCdEf" },
    { "name": "MB_ENCRYPTION_SECRET_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:111122223333:secret:metabase-encryption-key-AbCdEf" }
  ],
  "healthCheck": {
    "command": ["CMD-SHELL", "curl --fail -I http://localhost:3000/api/health || exit 1"],
    "interval": 15,
    "timeout": 5,
    "retries": 5,
    "startPeriod": 300
  }
}

Run the service with one task. By default, a rolling deployment keeps 100% of the desired tasks running and can start up to 200%. During an upgrade, the old and new versions would then run side by side. Set the minimum to 0% and the maximum to 100%, so ECS stops the old task before it starts the new one. Also give the service a health check grace period, so ECS doesn’t replace the task while its migrations run past the 300-second startPeriod:

aws ecs update-service --cluster CLUSTER --service metabase \
  --task-definition metabase \
  --desired-count 1 \
  --deployment-configuration minimumHealthyPercent=0,maximumPercent=100 \
  --health-check-grace-period-seconds 1800

Google Cloud Run. Cloud Run sends requests to port 8080 by default, so set --port 3000. With request-based billing, Cloud Run gives a container CPU only while it handles a request, so turn on instance-based billing with --no-cpu-throttling and keep one instance with --min-instances 1. Use /readyz for the startup probe and /livez for the liveness probe. To reach Cloud SQL for PostgreSQL on its private IP, the command adds Direct VPC egress with --network, --subnet and --vpc-egress private-ranges-only. Cloud Run can pull public images from Docker Hub, and Google recommends an Artifact Registry remote repository in front of Docker Hub for higher availability.

At deploy time, Cloud Run checks that the service account that runs the container can read every secret it uses. The Editor role, which the default service account may have, doesn’t include that permission, and Google recommends a service account of your own. So grant your service account the Secret Manager Secret Accessor role on both secrets before you deploy. Pin each secret to a version number, such as 1, instead of latest, as Google recommends for secrets passed as environment variables:

gcloud secrets add-iam-policy-binding metabase-db-password \
  --member=serviceAccount:SERVICE_ACCOUNT_EMAIL \
  --role=roles/secretmanager.secretAccessor
gcloud secrets add-iam-policy-binding metabase-encryption-key \
  --member=serviceAccount:SERVICE_ACCOUNT_EMAIL \
  --role=roles/secretmanager.secretAccessor

gcloud run deploy metabase \
  --image docker.io/metabase/metabase:v0.64.1 \
  --region REGION \
  --service-account SERVICE_ACCOUNT_EMAIL \
  --network NETWORK \
  --subnet SUBNET \
  --vpc-egress private-ranges-only \
  --port 3000 \
  --memory 4Gi \
  --no-cpu-throttling \
  --min-instances 1 \
  --allow-unauthenticated \
  --set-env-vars MB_DB_TYPE=postgres,MB_DB_HOST=10.20.0.5,MB_DB_PORT=5432,MB_DB_DBNAME=metabaseappdb,MB_DB_USER=metabase,JAVA_OPTS=-Xmx3g \
  --set-secrets MB_DB_PASS=metabase-db-password:1,MB_ENCRYPTION_SECRET_KEY=metabase-encryption-key:1 \
  --startup-probe httpGet.path=/readyz,httpGet.port=3000,periodSeconds=10,failureThreshold=60 \
  --liveness-probe httpGet.path=/livez,httpGet.port=3000

Fly.io. Name the app with app, deploy the image with a [build] section, and set internal_port to 3000, because Fly’s default is 8080. Set auto_stop_machines = "off" and auto_start_machines = false so the Machine keeps running between requests. On a 2 GB Machine, set the heap to -Xmx1g, as the Metabase troubleshooting guide suggests for 2 GB of RAM. Give the health check a grace_period long enough for the first start, and keep secrets out of fly.toml with fly secrets set. Fly doesn’t show a secret’s value again after you set it, so write the encryption key to a file first and store that file somewhere safe. The first fly deploy creates two Machines by default, so pass --ha=false to run one.

app = "your-app"

[build]
  image = "metabase/metabase:v0.64.1"

[env]
  MB_SITE_URL = "https://your-app.fly.dev"
  JAVA_OPTS = "-Xmx1g"

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = "off"
  auto_start_machines = false

  [[http_service.checks]]
    grace_period = "120s"
    interval = "30s"
    method = "GET"
    timeout = "5s"
    path = "/api/health"

[[vm]]
  memory = "2gb"
fly apps create your-app
openssl rand -base64 32 > metabase-encryption-key.txt
fly secrets set --stage \
  MB_DB_CONNECTION_URI="postgres://metabase:your_password@your-postgres-host:5432/metabaseappdb" \
  MB_ENCRYPTION_SECRET_KEY="$(cat metabase-encryption-key.txt)"
fly deploy --ha=false

Render. Render’s guide for Metabase uses a Render Postgres database for the application database and a Docker web service. Put FROM metabase/metabase:v0.64.1 in the Dockerfile, set MB_DB_CONNECTION_URI to the internal URL of the database, and add MB_ENCRYPTION_SECRET_KEY. Render recommends at least 1 GB of RAM, and at least 2 GB once you have more than a small amount of test data. Set the health check path to /api/health in the service settings, or with healthCheckPath in render.yaml. Render restarts an instance that fails its health check for 60 seconds, so with /api/health it also restarts Metabase while the application database is unreachable. The /livez path avoids those restarts because it doesn’t check the database. But it also answers while Metabase is still starting, so Render can then send traffic to a new deploy before Metabase is ready.

Next steps

# Run Metabase in production with Docker

To run Metabase in production with Docker, use the official `metabase/metabase` image with a pinned version tag such as `v0.64.1`, and keep the application database in PostgreSQL. Point Metabase at Postgres with the `MB_DB_*` environment variables or `MB_DB_CONNECTION_URI`, and pin a full version instead of `latest`, because Docker Hub has no `stable` tag. The open-source image is free to self-host with unlimited users, and the Pro and Enterprise plans run the `metabase/metabase-enterprise` image with a license token.

This page follows the Metabase docs for version 64 and was checked on 2026-10-07. It covers image tags, a docker compose file with Postgres, environment variables, the encryption key, the health check, Java memory, backups and upgrades. Short notes at the end cover AWS ECS Fargate, Google Cloud Run, Fly.io and Render.

## Which Metabase Docker image tag should you use?

Metabase versions follow the pattern License.Major.Minor.Patch, where the first number is 0 for the open-source edition and 1 for the Enterprise Edition. So `v0.64.1` is the first stable release of Metabase 64 in the open-source image, and `v1.64.1` is the same release in the `metabase/metabase-enterprise` image. Metabase also publishes patch releases with a fourth number, such as `v0.63.19.3`.

Docker Hub also carries moving tags: `v0.64.x` follows the newest build of Metabase 64, and `v0.64.1.x` follows the newest patch of 64.1. `latest` is a moving tag. Metabase moves it to a new major version some time after that version ships, so pin an exact version and upgrade on purpose. There is no `stable` tag either, so a full release tag such as `v0.64.1` is how you pin a stable version. The Metabase upgrade guide recommends a specific tag over `latest`, and the version support page doesn't recommend moving `.x` tags in production.

To find the current release, ask the GitHub releases API:

```bash
curl -s https://api.github.com/repos/metabase/metabase/releases/latest | jq -r .tag_name
```

Metabase 64 is a long-term support (LTS) release, supported until December 1, 2027, so pin a Metabase 64 release if you want fewer major upgrades. Docker Hub also has a `v0.64-lts` tag that follows the newest Metabase 64 build. Metabase 58 is the previous LTS release, supported until February 17, 2027. LTS versions get 14 months of support, which is longer than other versions get. Metabase 55 and 56 reached end of life on May 31, 2026.

## A production docker compose file with a Postgres application database

The application database is where Metabase keeps its users, questions, dashboards and settings. The default H2 database is a file inside the container, so you lose it when the container goes away, and Metabase recommends PostgreSQL for production. This compose file builds on the example from the Running Metabase on Docker page. It adds a pinned tag, a Docker secret for the password, an encryption key, a memory limit and a start period for the health check. It also publishes port 3000 on 127.0.0.1 only, so only the Docker host can reach Metabase's plain HTTP port. A reverse proxy with TLS on the same host then serves Metabase to your users. If the proxy runs on another machine, publish the port on the host's private address instead.

```yaml
# docker-compose.yml
services:
  metabase:
    # Pin a full version. Replace it with the current release from
    # https://github.com/metabase/metabase/releases
    image: metabase/metabase:v0.64.1
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000" # only this host can connect; put a reverse proxy with TLS in front
    environment:
      MB_DB_TYPE: postgres
      MB_DB_DBNAME: metabaseappdb
      MB_DB_PORT: 5432
      MB_DB_USER: metabase
      MB_DB_PASS_FILE: /run/secrets/db_password
      MB_DB_HOST: postgres
      MB_ENCRYPTION_SECRET_KEY: ${MB_ENCRYPTION_SECRET_KEY:?set MB_ENCRYPTION_SECRET_KEY in .env}
      MB_SITE_URL: https://metabase.example.com
      JAVA_OPTS: -Xmx2g
      JAVA_TIMEZONE: UTC # match the time zone of your reports
    secrets:
      - db_password
    mem_limit: 3g
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: curl --fail -I http://localhost:3000/api/health || exit 1
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 5m

  postgres:
    image: postgres:17 # with postgres:18 and later, mount the volume at /var/lib/postgresql
    restart: unless-stopped
    environment:
      POSTGRES_USER: metabase
      POSTGRES_DB: metabaseappdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U metabase -d metabaseappdb"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pg_data:

secrets:
  db_password:
    file: ./db_password.txt
```

Create the key file and the password file next to it, then start the stack:

```bash
echo "MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)" > .env
openssl rand -base64 24 > db_password.txt
docker compose up -d
docker compose logs -f metabase
```

Metabase creates its own tables on the first start, but it doesn't create the Postgres database itself, so here the postgres image creates it from `POSTGRES_DB`. Each Metabase environment, such as staging or production, needs its own application database. For the databases you analyze, Metabase recommends a dedicated read-only database user. Databases with official drivers, such as ClickHouse and PostgreSQL, work with the image as it is. Mount a `/plugins` directory only for community drivers or extra JDBC files, such as Oracle's.

## Environment variables for the application database

Metabase reads the application database settings when it starts. Set them one by one with `MB_DB_TYPE`, `MB_DB_DBNAME`, `MB_DB_PORT`, `MB_DB_USER`, `MB_DB_PASS` and `MB_DB_HOST`, as the compose file does, or put them in one JDBC URL with `MB_DB_CONNECTION_URI`:

```bash
MB_DB_CONNECTION_URI="jdbc:postgresql://db.example.com:5432/metabaseappdb?user=metabase&password=your_password"
```

With `MB_DB_CONNECTION_URI`, Metabase reads the database type from the URL, and it also accepts the `postgres://user:password@host:5432/database` form that hosts such as Heroku hand out. Metabase doesn't URL-decode a password written as `user:password@`, so if the password has special characters, pass it in `MB_DB_PASS` and leave it out of the URL. To check the server's certificate, add `sslmode=verify-ca&sslrootcert=...` to the URL. The application database must use the `public` schema.

On Docker, `MB_DB_USER`, `MB_DB_PASS`, `MB_DB_CONNECTION_URI`, `MB_EMAIL_SMTP_USERNAME`, `MB_EMAIL_SMTP_PASSWORD`, `MB_LDAP_PASSWORD` and `MB_LDAP_BIND_DN` also take a `_FILE` suffix that points to a Docker secret, as `MB_DB_PASS_FILE` does above. Set `MB_SITE_URL` to the address people use, because Metabase builds the links in its emails from it. The `JAVA_TIMEZONE` variable sets the Java time zone, and the Metabase docs suggest the time zone you want your reports in.

## Encrypt connection details with MB_ENCRYPTION_SECRET_KEY

`MB_ENCRYPTION_SECRET_KEY` makes Metabase encrypt the connection details of your databases inside the application database. Use a key of at least 16 characters, such as the output of `openssl rand -base64 32`, and set it before the first start. If you add a key to a Metabase that already holds data, run the `enable-encryption` command once. Metabase won't start while the key is set and the data isn't encrypted with it:

```bash
docker compose stop metabase
docker compose run --rm metabase enable-encryption
docker compose up -d metabase
```

Store the key somewhere safe, such as a password manager or a secrets manager, and keep it separate from the application database and its backups. You still need the key to use a backup. Without it, nobody can decrypt the backup's connection details, so you would have to enter them again for every database. To change the key later, use the `rotate-encryption-key` command with the new key.

## Health check: /api/health, /livez and /readyz

`GET /api/health` returns 200 with `{"status":"ok"}` once Metabase is ready and can reach its application database, and it returns a 503 while Metabase starts. The compose file checks it with `curl`, which the image includes, and `start_period` gives Metabase time to run its migrations, because failed checks in that window don't count. On platforms with separate probes, use `GET /livez` for liveness and `GET /readyz` for readiness and startup. A liveness check on `/api/health` alone can restart Metabase over and over while the application database is down, and `/livez` avoids that because it doesn't check the database.

## Java memory: JAVA_OPTS and -Xmx

Metabase needs at least 1 CPU core and 1 GB of RAM, plus 1 CPU and 2 GB for every 20 people using it at the same time. Set the Java heap with `JAVA_OPTS`, for example `-Xmx2g`, and keep it below the container's memory limit so the JVM has room for memory outside the heap. The compose file gives the container 3 GB and the heap 2 GB, and in its own load test Metabase set the heap to 80% of the RAM.

## Back up the application database

Back up the application database every day and before every upgrade. With the compose file above, `pg_dump` runs inside the Postgres container:

```bash
docker compose exec -T postgres pg_dump -U metabase -Fc metabaseappdb > "metabase-$(date +%F).dump"
```

To restore a backup, stop Metabase, load the dump with `pg_restore`, and start Metabase again:

```bash
docker compose stop metabase
docker compose exec -T postgres pg_restore -U metabase -d postgres --clean --if-exists --create < metabase-2026-10-07.dump
docker compose start metabase
```

With `--create`, pg_restore connects to the `postgres` database, drops `metabaseappdb` and recreates it from the archive, so tables that a newer Metabase version added don't survive the restore. PostgreSQL can't drop a database while someone else is connected to it, so stop Metabase first. The same command works for a rollback after an upgrade.

Copy the dumps off the host. On Amazon RDS, turn on automated backups instead.

## Upgrade Metabase and roll back

To upgrade, take a backup, change the image tag, and pull and start the new image. Metabase runs its database migrations when it starts, so wait for `/api/health` to return ok before you check the version:

```bash
# After the backup, set the new tag in docker-compose.yml.
docker compose pull metabase
docker compose up -d metabase
docker compose logs -f metabase
curl -s http://localhost:3000/api/session/properties | jq -r '.version.tag'
```

A minor upgrade, such as 63.18 to 63.19, rarely changes the application database. A major upgrade, such as 63 to 64, runs schema migrations, so plan for some downtime and move to the latest minor release of the new major version. If you run more than one Metabase container, scale down to one before a major upgrade, and make sure your platform doesn't stop Metabase while the migrations run.

To roll back, Metabase recommends that you restore the backup you took before the upgrade and start the old tag. If you need to keep changes made after the upgrade, run `migrate down` with the newer image first, then start the old tag:

```bash
docker compose stop metabase
docker compose run --rm metabase "migrate down"   # run this while docker-compose.yml still has the new tag
# Then set the old tag in docker-compose.yml.
docker compose up -d metabase
```

## Run the same image on AWS ECS Fargate, Google Cloud Run, Fly.io or Render

The same image and environment variables work on container platforms. Metabase runs scheduled work such as syncs, dashboard subscriptions and alerts inside the server, so keep one instance running and turn off settings that stop idle instances.

**AWS ECS Fargate.** Put the application database on Amazon RDS for PostgreSQL and turn on automated RDS backups. Fargate tasks use the `awsvpc` network mode, so you map only `containerPort` 3000. A task with 1 vCPU can take 2 to 8 GB of memory, and 4 GB fits the `-Xmx3g` heap below. Pass the password and the encryption key through `secrets`, whose `valueFrom` points to Secrets Manager or Parameter Store, and give the task execution role permission to read them. The container health check takes a `startPeriod` of up to 300 seconds.

```json
{
  "name": "metabase",
  "image": "metabase/metabase:v0.64.1",
  "portMappings": [{ "containerPort": 3000 }],
  "environment": [
    { "name": "MB_DB_TYPE", "value": "postgres" },
    { "name": "MB_DB_HOST", "value": "metabase-app.abc123xyz.us-east-1.rds.amazonaws.com" },
    { "name": "MB_DB_PORT", "value": "5432" },
    { "name": "MB_DB_DBNAME", "value": "metabaseappdb" },
    { "name": "MB_DB_USER", "value": "metabase" },
    { "name": "JAVA_OPTS", "value": "-Xmx3g" }
  ],
  "secrets": [
    { "name": "MB_DB_PASS", "valueFrom": "arn:aws:secretsmanager:us-east-1:111122223333:secret:metabase-db-password-AbCdEf" },
    { "name": "MB_ENCRYPTION_SECRET_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:111122223333:secret:metabase-encryption-key-AbCdEf" }
  ],
  "healthCheck": {
    "command": ["CMD-SHELL", "curl --fail -I http://localhost:3000/api/health || exit 1"],
    "interval": 15,
    "timeout": 5,
    "retries": 5,
    "startPeriod": 300
  }
}
```

Run the service with one task. By default, a rolling deployment keeps 100% of the desired tasks running and can start up to 200%. During an upgrade, the old and new versions would then run side by side. Set the minimum to 0% and the maximum to 100%, so ECS stops the old task before it starts the new one. Also give the service a health check grace period, so ECS doesn't replace the task while its migrations run past the 300-second `startPeriod`:

```bash
aws ecs update-service --cluster CLUSTER --service metabase \
  --task-definition metabase \
  --desired-count 1 \
  --deployment-configuration minimumHealthyPercent=0,maximumPercent=100 \
  --health-check-grace-period-seconds 1800
```

**Google Cloud Run.** Cloud Run sends requests to port 8080 by default, so set `--port 3000`. With request-based billing, Cloud Run gives a container CPU only while it handles a request, so turn on instance-based billing with `--no-cpu-throttling` and keep one instance with `--min-instances 1`. Use `/readyz` for the startup probe and `/livez` for the liveness probe. To reach Cloud SQL for PostgreSQL on its private IP, the command adds Direct VPC egress with `--network`, `--subnet` and `--vpc-egress private-ranges-only`. Cloud Run can pull public images from Docker Hub, and Google recommends an Artifact Registry remote repository in front of Docker Hub for higher availability.

At deploy time, Cloud Run checks that the service account that runs the container can read every secret it uses. The Editor role, which the default service account may have, doesn't include that permission, and Google recommends a service account of your own. So grant your service account the Secret Manager Secret Accessor role on both secrets before you deploy. Pin each secret to a version number, such as `1`, instead of `latest`, as Google recommends for secrets passed as environment variables:

```bash
gcloud secrets add-iam-policy-binding metabase-db-password \
  --member=serviceAccount:SERVICE_ACCOUNT_EMAIL \
  --role=roles/secretmanager.secretAccessor
gcloud secrets add-iam-policy-binding metabase-encryption-key \
  --member=serviceAccount:SERVICE_ACCOUNT_EMAIL \
  --role=roles/secretmanager.secretAccessor

gcloud run deploy metabase \
  --image docker.io/metabase/metabase:v0.64.1 \
  --region REGION \
  --service-account SERVICE_ACCOUNT_EMAIL \
  --network NETWORK \
  --subnet SUBNET \
  --vpc-egress private-ranges-only \
  --port 3000 \
  --memory 4Gi \
  --no-cpu-throttling \
  --min-instances 1 \
  --allow-unauthenticated \
  --set-env-vars MB_DB_TYPE=postgres,MB_DB_HOST=10.20.0.5,MB_DB_PORT=5432,MB_DB_DBNAME=metabaseappdb,MB_DB_USER=metabase,JAVA_OPTS=-Xmx3g \
  --set-secrets MB_DB_PASS=metabase-db-password:1,MB_ENCRYPTION_SECRET_KEY=metabase-encryption-key:1 \
  --startup-probe httpGet.path=/readyz,httpGet.port=3000,periodSeconds=10,failureThreshold=60 \
  --liveness-probe httpGet.path=/livez,httpGet.port=3000
```

**Fly.io.** Name the app with `app`, deploy the image with a `[build]` section, and set `internal_port` to 3000, because Fly's default is 8080. Set `auto_stop_machines = "off"` and `auto_start_machines = false` so the Machine keeps running between requests. On a 2 GB Machine, set the heap to `-Xmx1g`, as the Metabase troubleshooting guide suggests for 2 GB of RAM. Give the health check a `grace_period` long enough for the first start, and keep secrets out of `fly.toml` with `fly secrets set`. Fly doesn't show a secret's value again after you set it, so write the encryption key to a file first and store that file somewhere safe. The first `fly deploy` creates two Machines by default, so pass `--ha=false` to run one.

```toml
app = "your-app"

[build]
  image = "metabase/metabase:v0.64.1"

[env]
  MB_SITE_URL = "https://your-app.fly.dev"
  JAVA_OPTS = "-Xmx1g"

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = "off"
  auto_start_machines = false

  [[http_service.checks]]
    grace_period = "120s"
    interval = "30s"
    method = "GET"
    timeout = "5s"
    path = "/api/health"

[[vm]]
  memory = "2gb"
```

```bash
fly apps create your-app
openssl rand -base64 32 > metabase-encryption-key.txt
fly secrets set --stage \
  MB_DB_CONNECTION_URI="postgres://metabase:your_password@your-postgres-host:5432/metabaseappdb" \
  MB_ENCRYPTION_SECRET_KEY="$(cat metabase-encryption-key.txt)"
fly deploy --ha=false
```

**Render.** Render's guide for Metabase uses a Render Postgres database for the application database and a Docker web service. Put `FROM metabase/metabase:v0.64.1` in the Dockerfile, set `MB_DB_CONNECTION_URI` to the internal URL of the database, and add `MB_ENCRYPTION_SECRET_KEY`. Render recommends at least 1 GB of RAM, and at least 2 GB once you have more than a small amount of test data. Set the health check path to `/api/health` in the service settings, or with `healthCheckPath` in `render.yaml`. Render restarts an instance that fails its health check for 60 seconds, so with `/api/health` it also restarts Metabase while the application database is unreachable. The `/livez` path avoids those restarts because it doesn't check the database. But it also answers while Metabase is still starting, so Render can then send traffic to a new deploy before Metabase is ready.

## Next steps

- [Running Metabase on Docker](/docs/latest/installation-and-operation/running-metabase-on-docker)
- [Configuring the Metabase application database](/docs/latest/installation-and-operation/configuring-application-database)
- [Environment variables](/docs/latest/configuring-metabase/environment-variables)
- [Upgrading Metabase](/docs/latest/installation-and-operation/upgrading-metabase)
- [Backing up Metabase](/docs/latest/installation-and-operation/backing-up-metabase-application-data)
- [How to run Metabase in production](/learn/metabase-basics/administration/administration-and-operation/metabase-in-production)