
Metabaseとは、社内のデータベースに接続し、画面操作のクエリビルダーかSQLで集計した結果をグラフとダッシュボードにまとめて共有するBIツールです。ここで扱うのはAGPLで公開されているオープンソース版(検証は v0.64.3.4)で、データを貯める場所ではなく、既存のDBを読みに行って見せる層にあたります。
対象は、問い合わせ台帳や資産台帳の集計をExcelに貼って毎週配っている情シスです。Docker Composeで Metabase と PostgreSQL 18.6 を 127.0.0.1 だけに公開して起動し、初期設定、業務DBの接続、質問とダッシュボードの作成、一般ユーザーの権限分けまでを Metabase の API でやり直せる形にします。そのうえで、読み取り専用ユーザーが本当に書き込めないか、公開リンクと埋め込みの初期値、初期設定用エンドポイントの挙動を実際に叩いて確かめ、アプリケーションDBのダンプを別のスタックに戻してダッシュボードが復活するところまで見ます。
この記事でわかること
- 業務DBとMetabase用のアプリケーションDBを分けた2つの compose.yaml と、ポート・鍵・メモリ上限をそう決めた理由
- 初期設定、DB接続、クエリビルダーとSQLの質問、部署フィルター付きダッシュボードを API で作る手順
- 一般ユーザーにダッシュボードだけを見せる権限の組み方と、OSS版では選べない設定
- 読み取り専用ユーザー、公開リンク、埋め込み、初期設定用エンドポイントを実際に叩いた結果
- アプリケーションDBを pg_dump して別スタックに復元する手順と、暗号化キーが違うと起きること
検証した構成と前提
検証は Docker Engine 29.1.3 と Docker Compose 2.40.3 が入った Linux サーバーで、Docker Hub にある Metabase 配布のイメージ metabase/metabase:v0.64.3.4(オープンソース版)と、PostgreSQL の公式イメージ postgres:18.6 を使いました。ホスト側では curl、jq、openssl を使います。サービス、ボリューム、up と down といった Compose の基本はDocker Compose入門に書いたので、ここでは Metabase に固有の部分に絞ります。
docker --version
docker compose version
curl --version | head -n 1
jq --version
openssl versionDocker version 29.1.3, build 29.1.3-0ubuntu4.1
Docker Compose version 2.40.3+ds1-0ubuntu1
curl 8.18.0 (x86_64-pc-linux-gnu) libcurl/8.18.0 OpenSSL/3.5.5 zlib/1.3.1 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.21.2 libssh2/1.11.1 nghttp2/1.68.0 librtmp/2.3 mit-krb5/1.22.1 OpenLDAP/2.6.10
jq-1.8.1
OpenSSL 3.5.5 27 Jan 2026 (Library: OpenSSL 3.5.5 27 Jan 2026)構成は2つの Compose プロジェクトに分けます。~/helpdesk-db は「すでに社内にある業務DB」の役で、架空のヘルプデスクのチケットとIT資産台帳を入れた PostgreSQL です。~/metabase は Metabase 本体と、Metabase が自分の設定(ユーザー、質問、ダッシュボード、権限)を保存するアプリケーションDBです。分けておくと、Metabase 側を壊して作り直しても業務データには触れず、バックアップの対象もアプリケーションDBだけに絞れます。
| コンテナ(サービス) | イメージ | 役割 | ホストへの公開 | mem_limit |
|---|---|---|---|---|
| bizdb | postgres:18.6 | 業務DB(チケット150件、資産40台) | なし | 256m |
| appdb | postgres:18.6 | Metabase のアプリケーションDB | なし | 256m |
| metabase | metabase/metabase:v0.64.3.4 | Web画面とAPI | 127.0.0.1:13300 だけ | 2g |
コマンドに出てくる管理者のメールアドレス [email protected](表示名 Ryoma T)、一般ユーザーの Hanako Sato([email protected])、Compose のプロジェクト名 metabase-docker-dashboard・metabase-docker-dashboard-biz・metabase-docker-dashboard-restore、ネットワーク名 metabase-docker-dashboard-biznet、ポート 13300 と 13301、サイト名「社内ITダッシュボード」は検証用の例なので、自分の環境の値に読み替えてください。プロジェクト名を変えると、出力に出るコンテナ名とボリューム名も変わります。ID(DB、質問、ダッシュボード)は、この記事の手順を空の状態から順に実行したときの値です。
出力はすべて端末を割り当てない状態で記録したものです。対話的な端末で docker compose up を実行すると、同じ内容が進行表示付きで出ます。イメージが手元にない初回は、その前に pull の進捗が流れます。
業務DBを用意する
最初に業務DB役の PostgreSQL を立てます。手元にすでに業務DBがあるなら、この節は読み取り専用ユーザーを作る部分だけ読んで、自分のDBで同じ権限を作ってください。
mkdir -p ~/helpdesk-db && cd ~/helpdesk-dbこのディレクトリで次のコマンドを実行し、compose.yaml を書き出します。
cat > compose.yaml <<'EOF'
services:
bizdb:
image: postgres:18.6
restart: unless-stopped
environment:
POSTGRES_DB: helpdesk
POSTGRES_USER: helpdesk_owner
POSTGRES_PASSWORD: ${BIZDB_PASSWORD:?}
volumes:
- bizdb-data:/var/lib/postgresql
networks:
- biz
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U helpdesk_owner -d helpdesk"]
interval: 5s
timeout: 3s
retries: 12
mem_limit: 256m
networks:
biz:
name: ${BIZ_NETWORK:?}
volumes:
bizdb-data:
EOFimage: postgres:18.6はパッチ版まで固定します。18 系の公式イメージはボリュームを/var/lib/postgresqlに付ける前提で、古い記事にある/var/lib/postgresql/dataに付けると起動しません(「つまずきポイント」で再現します)。portsを書いていないので、このDBはホストのどのアドレスでも待ち受けません。届くのは、Docker のネットワーク経由の Metabase からだけです。networks.biz.nameに名前を固定しているのは、別プロジェクトの Metabase からexternalで参加させるためです。名前を固定しないと、Compose がプロジェクト名を前に付けた名前で作ります。mem_limit: 256mは上限です。実測は後の節で38MiB前後でした。
パスワードは .env に置きます。install -m 600 /dev/null .env で中身が空のファイルを所有者だけ読める権限で先に作り、そこへ書き込むので、書き込んだ瞬間に他のユーザーから読める時間ができません。
install -m 600 /dev/null .env
cat > .env <<EOF
COMPOSE_PROJECT_NAME=metabase-docker-dashboard-biz
BIZ_NETWORK=metabase-docker-dashboard-biznet
BIZDB_PASSWORD=$(openssl rand -hex 16)
EOFdocker compose up -d --wait Network metabase-docker-dashboard-biznet Creating
Network metabase-docker-dashboard-biznet Created
Volume metabase-docker-dashboard-biz_bizdb-data Creating
Volume metabase-docker-dashboard-biz_bizdb-data Created
Container metabase-docker-dashboard-biz-bizdb-1 Creating
Container metabase-docker-dashboard-biz-bizdb-1 Created
Container metabase-docker-dashboard-biz-bizdb-1 Starting
Container metabase-docker-dashboard-biz-bizdb-1 Started
Container metabase-docker-dashboard-biz-bizdb-1 Waiting
Container metabase-docker-dashboard-biz-bizdb-1 Healthy--wait を付けると、ヘルスチェックが healthy になるまで戻ってきません。次に架空のデータを入れるSQLを書き出します。乱数を使わず generate_series と剰余で値を決めているので、誰が流しても同じ件数になり、この記事の出力と照合できます。
cat > schema.sql <<'EOF'
CREATE SCHEMA ops;
CREATE TABLE ops.assets (
asset_tag text PRIMARY KEY,
kind text NOT NULL,
department text NOT NULL,
purchased_on date NOT NULL,
status text NOT NULL
);
CREATE TABLE ops.tickets (
id integer PRIMARY KEY,
opened_on date NOT NULL,
closed_on date,
department text NOT NULL,
category text NOT NULL,
priority text NOT NULL,
asset_tag text REFERENCES ops.assets (asset_tag),
title text NOT NULL
);
-- 架空の資産台帳 40 台
INSERT INTO ops.assets
SELECT format('IT-%s', lpad(i::text, 3, '0')),
(ARRAY['ノートPC', 'ノートPC', 'デスクトップPC', 'モニター', 'スマートフォン'])[1 + i % 5],
(ARRAY['営業部', '開発部', '総務部', '経理部'])[1 + i % 4],
date '2022-04-01' + (i * 37) % 1460,
CASE WHEN i % 13 = 0 THEN '修理中' WHEN i % 9 = 0 THEN '廃棄予定' ELSE '稼働中' END
FROM generate_series(1, 40) AS i;
-- 架空の問い合わせ 150 件(乱数を使わないので、誰が流しても同じ件数になる)
INSERT INTO ops.tickets
SELECT i,
date '2026-08-03' + (i * 7) % 82,
CASE WHEN i % 6 = 0 OR i % 7 = 0 THEN NULL
ELSE date '2026-08-03' + (i * 7) % 82 + i % 4 END,
(ARRAY['営業部', '営業部', '開発部', '総務部', '経理部'])[1 + (i * 13 / 7) % 5],
c.category,
CASE WHEN i % 9 = 0 THEN '高' WHEN i % 3 = 0 THEN '中' ELSE '低' END,
CASE WHEN c.category = 'PC・周辺機器' THEN format('IT-%s', lpad((1 + i % 40)::text, 3, '0')) END,
CASE c.category
WHEN 'アカウント' THEN 'パスワードの再設定依頼'
WHEN 'PC・周辺機器' THEN 'ノートPCの画面が映らない'
WHEN 'ネットワーク' THEN 'VPNに接続できない'
WHEN '業務システム' THEN '経費精算システムでエラーが出る'
ELSE '会議室モニターの使い方'
END
FROM generate_series(1, 150) AS i,
LATERAL (SELECT (ARRAY['アカウント', 'アカウント', 'アカウント', 'PC・周辺機器', 'PC・周辺機器',
'ネットワーク', 'ネットワーク', '業務システム', '業務システム', 'その他'])[1 + i % 10] AS category) AS c;
EOFdocker compose exec -T bizdb psql -U helpdesk_owner -d helpdesk -v ON_ERROR_STOP=1 < schema.sqlCREATE SCHEMA
CREATE TABLE
CREATE TABLE
INSERT 0 40
INSERT 0 150docker compose exec -T bizdb psql -U helpdesk_owner -d helpdesk -c "SELECT category, count(*) AS total, count(*) FILTER (WHERE closed_on IS NULL) AS open FROM ops.tickets GROUP BY category ORDER BY open DESC, category" category | total | open
--------------+-------+------
アカウント | 45 | 15
業務システム | 30 | 10
PC・周辺機器 | 30 | 8
ネットワーク | 30 | 8
その他 | 15 | 2
(5 rows)未完了(closed_on が NULL)は150件中43件です。この数字は後でダッシュボードの値と突き合わせます。
続いて Metabase 専用の読み取り専用ユーザー metabase_ro を作ります。
cat > readonly.sql <<'EOF'
CREATE ROLE metabase_ro LOGIN PASSWORD :'ro_password';
ALTER ROLE metabase_ro SET default_transaction_read_only = on;
GRANT CONNECT ON DATABASE helpdesk TO metabase_ro;
GRANT USAGE ON SCHEMA ops TO metabase_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA ops TO metabase_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE helpdesk_owner IN SCHEMA ops GRANT SELECT ON TABLES TO metabase_ro;
EOFALTER ROLE … SET default_transaction_read_only = onは、このユーザーのトランザクションを既定で読み取り専用にします。ただし、後で試すとおりセッション側から外せるので、これだけには頼りません。- 本当の歯止めは
GRANT SELECTだけを与えることです。UPDATE、INSERT、DELETE の権限がなければ、読み取り専用の設定を外されても書き込めません。 ALTER DEFAULT PRIVILEGES FOR ROLE helpdesk_ownerは、helpdesk_ownerがこれからopsに作るテーブルにも自動で SELECT を付けます。これがないと、テーブルを足すたびに GRANT し直すことになります。
パスワードは ro-password.txt に生成して psql の変数 ro_password で渡します。SQLファイルにもシェルの履歴にもパスワードの文字列が残りません。
install -m 600 /dev/null ro-password.txt
openssl rand -hex 16 > ro-password.txt
docker compose exec -T bizdb psql -U helpdesk_owner -d helpdesk -v ON_ERROR_STOP=1 -v ro_password="$(cat ro-password.txt)" < readonly.sqlCREATE ROLE
ALTER ROLE
GRANT
GRANT
GRANT
ALTER DEFAULT PRIVILEGES作ったユーザーで、読めることと書けないことを psql から直接確かめます。コンテナ内のソケット経由の接続だったので、このコマンドではパスワードを聞かれませんでした(Metabase はネットワーク経由で、パスワード付きで接続します)。
docker compose exec -T bizdb psql -U metabase_ro -d helpdesk -c "SELECT count(*) FROM ops.tickets" -c "UPDATE ops.assets SET status = '廃棄予定' WHERE asset_tag = 'IT-001'" count
-------
150
(1 row)
ERROR: cannot execute UPDATE in a read-only transactionDocker ComposeでMetabase本体を起動する
別のディレクトリに Metabase 本体とアプリケーションDBのプロジェクトを作ります。
mkdir -p ~/metabase && cd ~/metabasecat > compose.yaml <<'EOF'
services:
appdb:
image: postgres:18.6
restart: unless-stopped
environment:
POSTGRES_DB: metabase
POSTGRES_USER: metabase
POSTGRES_PASSWORD: ${APPDB_PASSWORD:?}
volumes:
- appdb-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U metabase -d metabase"]
interval: 5s
timeout: 3s
retries: 12
mem_limit: 256m
metabase:
image: metabase/metabase:v0.64.3.4
restart: unless-stopped
depends_on:
appdb:
condition: service_healthy
ports:
- "127.0.0.1:${MB_PORT:?}:3000"
environment:
MB_DB_TYPE: postgres
MB_DB_HOST: appdb
MB_DB_PORT: "5432"
MB_DB_DBNAME: metabase
MB_DB_USER: metabase
MB_DB_PASS: ${APPDB_PASSWORD:?}
MB_ENCRYPTION_SECRET_KEY: ${MB_ENCRYPTION_SECRET_KEY:?}
MB_SESSION_SECRET_KEY: ${MB_SESSION_SECRET_KEY:?}
MB_LOAD_SAMPLE_CONTENT: "false"
JAVA_TIMEZONE: Asia/Tokyo
networks:
- default
- biz
healthcheck:
test: ["CMD-SHELL", "curl -fs http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 5s
retries: 30
mem_limit: 2g
networks:
biz:
external: true
name: ${BIZ_NETWORK:?}
volumes:
appdb-data:
EOF各設定をこう書いた理由です。
image: metabase/metabase:v0.64.3.4:metabase/metabaseがオープンソース版、metabase/metabase-enterpriseが有料版(Pro / Enterprise)のイメージです(執筆時点の公式ドキュメント)。パッチ版まで固定し、pull し直しても版が勝手に上がらないようにします。ports: "127.0.0.1:${MB_PORT:?}:3000":ホストのループバックだけで待ち受けます。"3000:3000"と書くと全インターフェースに公開され、次の手順で見るとおり、初期設定が終わるまでは誰でも管理者を作れる状態がそのままネットワークに出ます。社内の他のPCから見せる段階では、TLS を終端するリバースプロキシを前に置きます(nginxのリバースプロキシ構築。この記事ではそこまで試していません)。MB_DB_TYPEからMB_DB_PASS:アプリケーションDBを PostgreSQL にします。指定しないとコンテナ内の H2 ファイルに保存され、公式ドキュメントは本番で使わないよう求め、PostgreSQL を推奨しています。MB_ENCRYPTION_SECRET_KEY:接続先DBのパスワードなど接続情報を、アプリケーションDBに暗号化して保存するための鍵です。鍵は16文字以上が必要で、設定しない限り暗号化は行われません(公式ドキュメント)。設定後はencryption-enabledが true になることを後で確認します。MB_SESSION_SECRET_KEY:アプリケーションDBに保存するセッションを、この鍵で署名させます。これを入れておくと、DBに触れるだけではセッションを偽造できなくなります。条件は16文字以上で、値を変えると全員がログアウトします(公式ドキュメント)。MB_LOAD_SAMPLE_CONTENT: "false":既定では H2 で作られたサンプルDBが入ります。このサンプルDB経由の脆弱性が Metabase 自身のセキュリティアドバイザリに複数載っているので(後述)、使わないものは最初から入れません。JAVA_TIMEZONE: Asia/Tokyo:起動スクリプトが Java のタイムゾーンに渡します。集計結果の日付が+09:00付きで返るようになります。networks:Metabase だけがdefault(アプリケーションDB側)とbiz(業務DB側)の両方に入ります。業務DBのコンテナからアプリケーションDBには経路がありません。healthcheck:イメージに curl が入っているので、/api/healthが成功したら healthy にします。mem_limit: 2g:Java のヒープの上限はこの値から自動で決まります。1g では起動中に止まりました(「つまずきポイント」)。
.env には3つの鍵とパスワードを生成して入れます。MB_ENCRYPTION_SECRET_KEY と MB_SESSION_SECRET_KEY は、後で復元するときに同じ値が要ります。この .env はダンプと同じくらい大事な秘密なので、権限 600 のまま、ダンプとは別の場所にも保管してください。
install -m 600 /dev/null .env
cat > .env <<EOF
COMPOSE_PROJECT_NAME=metabase-docker-dashboard
MB_PORT=13300
BIZ_NETWORK=metabase-docker-dashboard-biznet
APPDB_PASSWORD=$(openssl rand -hex 16)
MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)
MB_SESSION_SECRET_KEY=$(openssl rand -hex 32)
EOFtime docker compose up -d --wait Container metabase-docker-dashboard-appdb-1 Waiting
Container metabase-docker-dashboard-appdb-1 Healthy
Container metabase-docker-dashboard-metabase-1 Starting
Container metabase-docker-dashboard-metabase-1 Started
Container metabase-docker-dashboard-metabase-1 Waiting
Container metabase-docker-dashboard-appdb-1 Waiting
Container metabase-docker-dashboard-appdb-1 Healthy
Container metabase-docker-dashboard-metabase-1 Healthy
real 0m46.749s
user 0m0.156s
sys 0m0.091sアプリケーションDBが healthy になってから Metabase が起動し、/api/health が通るまで、この環境では46.7秒かかりました。
この時点で、認証なしの /api/session/properties に初期設定用のトークンが出ています。
curl -s http://127.0.0.1:13300/api/session/properties | jq '{version: .version.tag, "setup-token", "has-user-setup"}'{
"version": "v0.64.3.4",
"setup-token": "9dfe72a5-5733-4a80-9cdc-f4d27f7adc0c",
"has-user-setup": false
}setup-token を知っていれば、最初の管理者を作れます。ポートを外に出したまま放置しないで、起動したらすぐ初期設定を済ませます。
初期設定をAPIで済ませる
画面のウィザードでも初期設定はできますが、ここでは同じ内容を API で送り、手順を記録として残します。最初に、ログイン後のセッションIDを付けて API を呼ぶ小さなスクリプトを作ります。
cat > mbapi <<'EOF'
#!/bin/sh
# 使い方: ./mbapi メソッド パス [JSON文字列 または @ファイル名]
set -eu
. ./.env
session=$(cat "${SESSION_FILE:-session.txt}")
if [ $# -ge 3 ]; then
curl -sS -X "$1" -H "X-Metabase-Session: $session" -H 'Content-Type: application/json' \
--data-binary "$3" "http://127.0.0.1:$MB_PORT$2"
else
curl -sS -X "$1" -H "X-Metabase-Session: $session" "http://127.0.0.1:$MB_PORT$2"
fi
EOF
chmod 700 mbapi./mbapi GET /api/user/current のように使います。.env の MB_PORT を読み、session.txt(環境変数 SESSION_FILE で別のファイルに切り替え)に入れたセッションIDを X-Metabase-Session ヘッダーで送ります。セッションIDはパスワードと同じ扱いなので、ファイルは 600 で作ります。
パスワードの強度検査を先に見ておきます。数字を含まない28文字のパスフレーズで初期設定を送ると、400 が返りました。サイトの言語をまだ設定していないので、メッセージは英語です。
curl -s http://127.0.0.1:13300/api/session/properties | jq '{token: ."setup-token", user: {first_name: "Ryoma", last_name: "T", email: "[email protected]", password: "correct-horse-battery-staple"}, prefs: {site_name: "社内ITダッシュボード", site_locale: "ja"}}' | curl -s -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:13300/api/setup | jq -c .errors{"user":{"password":"password is too common."}}長さは足りていても「too common」と返るので、原因が分かりにくい箇所です。既定のパスワード規則 normal は数字を1文字以上要求する、と公式ドキュメントにあります。openssl rand -hex 16 の32文字なら通ります。
install -m 600 /dev/null admin-password.txt
openssl rand -hex 16 > admin-password.txt
install -m 600 /dev/null setup.json
curl -s http://127.0.0.1:13300/api/session/properties | jq --rawfile pw admin-password.txt '{token: ."setup-token", user: {first_name: "Ryoma", last_name: "T", email: "[email protected]", password: ($pw | rtrimstr("\n")), site_name: "社内ITダッシュボード"}, prefs: {site_name: "社内ITダッシュボード", site_locale: "ja"}}' > setup.json
install -m 600 /dev/null session.txt
curl -s -X POST -H 'Content-Type: application/json' --data-binary @setup.json http://127.0.0.1:13300/api/setup | jq -r .id > session.txt
wc -c < session.txt37wc -c の37は、36文字のセッションIDと改行です。パスワードもセッションIDも画面には出さず、ファイルにだけ残しています。prefs.site_locale を ja にしたので、以降のエラーメッセージは日本語になります。
./mbapi GET /api/user/current | jq -c '{email, common_name, is_superuser}'
curl -s http://127.0.0.1:13300/api/session/properties | jq -c '{"setup-token", "has-user-setup", "site-locale"}'{"email":"[email protected]","common_name":"Ryoma T","is_superuser":true}
{"setup-token":null,"has-user-setup":true,"site-locale":"ja"}初期設定が終わると setup-token は null になります。同じ内容をもう一度送っても、トークンが一致しないとして拒否されました。
curl -s -X POST -H 'Content-Type: application/json' --data-binary @setup.json http://127.0.0.1:13300/api/setup | jq -c .errors
rm setup.json{"token":"トークンがセットアップトークンと一致しません。"}初期設定用トークンは一度使うと消され、二度と使えない、というのが公式ドキュメントの説明です。手元の setup.json にはパスワードが入っているので、確認が済んだら消します。
業務DBを読み取り専用ユーザーで接続する
Metabase に業務DBを登録します。接続ユーザーは先ほどの metabase_ro です。パスワードは ../helpdesk-db/ro-password.txt から jq で読み込み、JSONファイルは送信後に消します。
install -m 600 /dev/null database.json
jq -n --rawfile pw ../helpdesk-db/ro-password.txt '{engine: "postgres", name: "ヘルプデスクDB", details: {host: "bizdb", port: 5432, dbname: "helpdesk", user: "metabase_ro", password: ($pw | rtrimstr("\n")), ssl: false, "schema-filters-type": "inclusion", "schema-filters-patterns": "ops"}}' > database.json
./mbapi POST /api/database @database.json | jq .id | tee db-id.txt
rm database.json1host: "bizdb"は Docker ネットワーク上のサービス名です。名前で引けるのは、Metabase のコンテナがbizネットワークに入っているからです。schema-filters-type: "inclusion"とschema-filters-patterns: "ops"で、同期の対象をopsスキーマだけにしています。ssl: falseは同じホストの Docker ネットワーク内だからで、別のサーバーにあるDBにつなぐなら TLS を有効にします。
登録直後に、Metabase はテーブルと列の情報を読み込みます(同期)。終わるまで待ち、テーブルと列のIDを取り出します。
until [ "$(./mbapi GET "/api/database/$(cat db-id.txt)" | jq -r .initial_sync_status)" = complete ]; do sleep 2; done
./mbapi GET "/api/database/$(cat db-id.txt)/metadata" > metadata.json
jq -c '.tables[] | {id, name, fields: [.fields[].name]}' metadata.json
jq '{db: .id, tickets: (.tables[] | select(.name == "tickets") | {id, fields: (.fields | map({(.name): .id}) | add)})}' metadata.json > ids.json
jq -c . ids.json{"id":1,"name":"assets","fields":["asset_tag","kind","department","purchased_on","status"]}
{"id":2,"name":"tickets","fields":["id","opened_on","closed_on","department","category","priority","asset_tag","title"]}
{"db":1,"tickets":{"id":2,"fields":{"id":6,"opened_on":7,"closed_on":8,"department":9,"category":10,"priority":11,"asset_tag":12,"title":13}}}ids.json は列名からIDを引くための対応表です。以降のJSONはここからIDを埋めるので、IDの数字を手で書き写す必要がありません。ついでに、ダッシュボードに出る名前を日本語にしておきます。
./mbapi PUT "/api/table/$(jq .tickets.id ids.json)" '{"display_name": "チケット"}' | jq -c '{id, display_name}'
./mbapi PUT "/api/field/$(jq .tickets.fields.category ids.json)" '{"display_name": "カテゴリ"}' | jq -c '{id, display_name}'
./mbapi PUT "/api/field/$(jq .tickets.fields.department ids.json)" '{"display_name": "部署"}' | jq -c '{id, display_name}'{"id":2,"display_name":"チケット"}
{"id":10,"display_name":"カテゴリ"}
{"id":9,"display_name":"部署"}クエリビルダーとSQLで質問を作る
Metabase では、グラフ1枚分の問い合わせを「質問(question)」と呼び、「コレクション」というフォルダーに入れて共有します。権限もコレクション単位で付けるので、先に入れ物を作ります。
./mbapi POST /api/collection '{"name": "ヘルプデスク"}' | jq .id | tee collection-id.txt31つ目は、未完了チケットをカテゴリ別に数える棒グラフです。type: "query" は、クエリビルダーの質問と同じ MBQL で書く指定です。
jq --argjson coll "$(cat collection-id.txt)" '{
name: "未完了チケット(カテゴリ別)",
collection_id: $coll,
display: "bar",
visualization_settings: {},
dataset_query: {
database: .db,
type: "query",
query: {
"source-table": .tickets.id,
filter: ["is-null", ["field", .tickets.fields.closed_on, null]],
aggregation: [["count"]],
breakout: [["field", .tickets.fields.category, null]],
"order-by": [["desc", ["aggregation", 0]], ["asc", ["field", .tickets.fields.category, null]]]
}
}
}' ids.json > card-open.json
./mbapi POST /api/card @card-open.json | jq .id | tee card-open-id.txt
./mbapi POST "/api/card/$(cat card-open-id.txt)/query" | jq -c '.data.rows'1
[["アカウント",15],["業務システム",10],["PC・周辺機器",8],["ネットワーク",8],["その他",2]]filter の is-null が「完了日が空」、breakout が「カテゴリごと」、aggregation の count が件数です。合計は43件で、psql で数えた未完了の件数と一致します。件数が同じカテゴリ(8件が2つ)の並びが実行ごとに変わらないよう、order-by の2つ目にカテゴリ名を入れています。
2つ目は SQL で書く、週ごとの受付件数と完了件数の折れ線です。
cat > weekly.sql <<'EOF'
SELECT date_trunc('week', opened_on)::date AS "週",
count(*) AS "受付",
count(closed_on) AS "完了"
FROM ops.tickets
WHERE true [[AND department = {{department}}]]
GROUP BY 1
ORDER BY 1
EOF
jq --argjson coll "$(cat collection-id.txt)" --rawfile sql weekly.sql '{
name: "週別の受付件数と完了件数",
collection_id: $coll,
display: "line",
visualization_settings: {},
dataset_query: {
database: .db,
type: "native",
native: {
query: $sql,
"template-tags": {department: {id: "department", name: "department", "display-name": "部署", type: "text"}}
}
}
}' ids.json > card-weekly.json
./mbapi POST /api/card @card-weekly.json | jq .id | tee card-weekly-id.txt
./mbapi POST "/api/card/$(cat card-weekly-id.txt)/query" | jq -c '{row_count, first: .data.rows[0]}'2
{"row_count":12,"first":["2026-08-03T00:00:00+09:00",12,9]}{{department}} はダッシュボードのフィルターから値を受け取る変数で、[[ … ]] で囲んだ部分は変数が空のときだけ丸ごと消えます。フィルターなしでは12週分の12行が返りました。WHERE true を置いているのは、消えたときにも SQL として成り立たせるためです。
ダッシュボードにまとめて部署フィルターを付ける
2つの質問を横に並べ、「部署」フィルターを1つ付けます。フィルターは、クエリビルダーの質問には列 department として(target が dimension)、SQL の質問には変数 department として(target が variable)結び付けます。質問の種類によって結び付け先の書き方が違うので、parameter_mappings はカードごとに書きます。
jq -n --argjson coll "$(cat collection-id.txt)" '{name: "ヘルプデスク概況", collection_id: $coll, parameters: [{id: "dept", name: "部署", slug: "department", type: "string/=", sectionId: "string"}]}' > dashboard.json
./mbapi POST /api/dashboard @dashboard.json | jq .id | tee dashboard-id.txt
jq --argjson open "$(cat card-open-id.txt)" --argjson weekly "$(cat card-weekly-id.txt)" '{dashcards: [
{id: -1, card_id: $open, row: 0, col: 0, size_x: 12, size_y: 8,
parameter_mappings: [{parameter_id: "dept", card_id: $open, target: ["dimension", ["field", .tickets.fields.department, null]]}]},
{id: -2, card_id: $weekly, row: 0, col: 12, size_x: 12, size_y: 8,
parameter_mappings: [{parameter_id: "dept", card_id: $weekly, target: ["variable", ["template-tag", "department"]]}]}
]}' ids.json > dashcards.json
./mbapi PUT "/api/dashboard/$(cat dashboard-id.txt)" @dashcards.json | jq -c '[.dashcards[] | {id, card_id}]'1
[{"id":1,"card_id":1},{"id":2,"card_id":2}]各カードを「部署 = 開発部」で実行して、フィルターが両方に効いているかを確かめるスクリプトを作ります。後で一般ユーザーの権限や復元後の確認にも使います。
cat > dash-check.sh <<'EOF'
#!/bin/sh
# ダッシュボードの各カードを「部署 = $1」で実行し、件数と先頭行を表示する
set -eu
d=$(cat dashboard-id.txt)
./mbapi GET "/api/dashboard/$d" | jq -r '.dashcards | sort_by(.id)[] | "\(.id) \(.card_id)"' | while read -r dc card; do
./mbapi POST "/api/dashboard/$d/dashcard/$dc/card/$card/query" \
"{\"parameters\": [{\"id\": \"dept\", \"type\": \"string/=\", \"value\": [\"$1\"]}]}" \
| jq -c --arg card "$card" '{card: ($card | tonumber), status, row_count, first: .data.rows[0]}'
done
EOF
chmod 700 dash-check.sh
./dash-check.sh 開発部{"card":1,"status":"completed","row_count":4,"first":["PC・周辺機器",3]}
{"card":2,"status":"completed","row_count":8,"first":["2026-08-03T00:00:00+09:00",3,2]}1枚目は未完了チケットのある4カテゴリ、2枚目は開発部の問い合わせがあった8週に絞られました。フィルターなしの5行・12行から減っているので、両方のカードに効いています。
一般ユーザーに見せる範囲を決める
ここからが社内で使う前に必ず決める部分です。権限は「グループ」に付け、ユーザーはグループに入れるのが Metabase のやり方です。データの権限(そのDBを見られるか、新しい問い合わせを作れるか)とコレクションの権限(どの質問とダッシュボードを見られるか)は別々に設定します。まず閲覧用のグループと一般ユーザーを作ります。
./mbapi POST /api/permissions/group '{"name": "ヘルプデスク閲覧"}' | jq .id | tee group-id.txt5install -m 600 /dev/null hanako-password.txt
install -m 600 /dev/null user.json
openssl rand -hex 16 > hanako-password.txt
jq -n --rawfile pw hanako-password.txt --argjson g "$(cat group-id.txt)" '{first_name: "Hanako", last_name: "Sato", email: "[email protected]", password: ($pw | rtrimstr("\n")), user_group_memberships: [{id: 1}, {id: $g}]}' > user.json
./mbapi POST /api/user @user.json | jq -c '{id, common_name, is_superuser, groups: [.user_group_memberships[].id]}'
rm user.json{"id":2,"common_name":"Hanako Sato","is_superuser":false,"groups":[1,5]}user_group_memberships には全員が入る組み込みグループ All Users(id 1)と、作ったグループ(id 5)の両方を入れています。初期設定と違い、ユーザー作成の API ではパスワードの強度検査が効きませんでした。password という文字列でユーザーを作り、そのままログインできたのが次の結果です(確認後すぐ無効化しています)。
jq -n '{first_name: "Weak", last_name: "Test", email: "[email protected]", password: "password"}' > weak-user.json
./mbapi POST /api/user @weak-user.json | jq .id | tee weak-user-id.txt
curl -s -o /dev/null -w 'login: %{http_code}\n' -X POST -H 'Content-Type: application/json' --data '{"username": "[email protected]", "password": "password"}' http://127.0.0.1:13300/api/session
./mbapi DELETE "/api/user/$(cat weak-user-id.txt)"
rm weak-user.json weak-user-id.txt3
login: 200
{"success":true}API でユーザーを作るときは、この記事のように強いパスワードを生成して渡す側が責任を持つことになります。次に、権限を何も変えていない状態で Hanako としてログインし、業務DBに任意の SQL を投げてみます。
install -m 600 /dev/null hanako-session.txt
jq -n --rawfile pw hanako-password.txt '{username: "[email protected]", password: ($pw | rtrimstr("\n"))}' | curl -s -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:13300/api/session | jq -r .id > hanako-session.txt
jq -n --argjson db "$(cat db-id.txt)" '{database: $db, type: "native", native: {query: "SELECT count(*) FROM ops.tickets"}}' > q-count.json
SESSION_FILE=hanako-session.txt ./mbapi POST /api/dataset @q-count.json | jq -c '{status, rows: .data.rows}'{"status":"completed","rows":[[150]]}一般ユーザーが、そのまま業務DBに SQL を実行できました。初期状態の権限を見ると理由が分かります。
./mbapi GET /api/permissions/graph | jq -c --arg db "$(cat db-id.txt)" --arg g "$(cat group-id.txt)" '.groups | to_entries[] | select(.key == "1" or .key == $g) | {group: .key, perms: .value[$db]}'
./mbapi GET /api/collection/graph | jq -c '.groups'{"group":"1","perms":{"download":{"schemas":"full"},"view-data":"unrestricted","create-queries":"query-builder-and-native"}}
{"group":"5","perms":{"download":{"schemas":"full"},"view-data":"unrestricted","create-queries":"query-builder-and-native"}}
{"2":{"root":"write","3":"write"},"1":{"root":"write","3":"write"}}All Users("1")にも新しいグループ("5")にも、view-data: unrestricted と create-queries: query-builder-and-native(クエリビルダーとSQLの両方)が付いています。コレクションも All Users が root(共有コレクションの最上位)とヘルプデスク(3)に write を持っています。DBを登録した瞬間に、全員がSQLを書けて、全員が共有コレクションを編集できる状態です。
All Users からDBを完全に隠そうとして view-data を blocked にすると、OSS版では拒否されます。
./mbapi GET /api/permissions/graph | jq --arg db "$(cat db-id.txt)" '{revision, groups: {"1": {($db): {"view-data": "blocked"}}}}' > perm-blocked.json
./mbapi PUT /api/permissions/graph @perm-blocked.json; echoblocked の権限機能は、advanced-permissions 機能付きのプレミアムトークンがある場合にのみ有効です。「View data」の細かい設定(Blocked、行と列のセキュリティなど)は Pro と Enterprise だけで、OSS版は「Can view」固定だと公式ドキュメントにも書かれています。そこで OSS版では「新しい問い合わせを作れるか(create-queries)」とコレクションの権限で絞ります。両グループの create-queries を外し、コレクションは All Users から取り上げて、閲覧グループにヘルプデスクの read だけを与えます。Hanako は All Users にも入っているので、All Users 側も絞らないとグループを分けた意味がありません(片方だけ絞った状態は試していません)。
./mbapi GET /api/permissions/graph | jq --arg db "$(cat db-id.txt)" --arg g "$(cat group-id.txt)" '{revision, groups: {"1": {($db): {"create-queries": "no"}}, ($g): {($db): {"create-queries": "no"}}}}' > perm-data.json
./mbapi PUT /api/permissions/graph @perm-data.json | jq -c --arg db "$(cat db-id.txt)" --arg g "$(cat group-id.txt)" '.groups | to_entries[] | select(.key == "1" or .key == $g) | {group: .key, perms: .value[$db]}'
./mbapi GET /api/collection/graph | jq --arg c "$(cat collection-id.txt)" --arg g "$(cat group-id.txt)" '{revision, groups: {"1": {root: "none", ($c): "none"}, ($g): {($c): "read"}}}' > perm-collection.json
./mbapi PUT /api/collection/graph @perm-collection.json | jq -c '.groups'{"group":"1","perms":{"download":{"schemas":"full"},"view-data":"unrestricted"}}
{"group":"5","perms":{"download":{"schemas":"full"},"view-data":"unrestricted"}}
{"2":{"root":"write","3":"write"},"5":{"3":"read"}}権限グラフの更新には、現在の revision を添えて送ります。毎回 GET した最新の revision から JSON を作っているのはそのためです(古い revision を送った場合の挙動は試していません)。Hanako で同じことを試します。
SESSION_FILE=hanako-session.txt ./mbapi POST /api/dataset @q-count.json | jq -c '{status, error}'
SESSION_FILE=hanako-session.txt ./mbapi POST /api/card @card-open.json; echo
SESSION_FILE=hanako-session.txt ./mbapi GET /api/collection/root/items | jq -c '[.data[] | {model, name}]'
SESSION_FILE=hanako-session.txt ./dash-check.sh 開発部{"status":"failed","error":"このクエリを実行する権限がありません。"}
この質問はクエリを実行する権限がないため保存できません。
[{"model":"collection","name":"ヘルプデスク"}]
{"card":1,"status":"completed","row_count":4,"first":["PC・周辺機器",3]}
{"card":2,"status":"completed","row_count":8,"first":["2026-08-03T00:00:00+09:00",3,2]}| 操作(Hanako) | 初期状態 | 権限変更後 |
|---|---|---|
| 業務DBへの任意のSQL(/api/dataset) | 実行できた(150件) | 「このクエリを実行する権限がありません。」 |
| 質問の保存(/api/card) | 試していない | 「この質問はクエリを実行する権限がないため保存できません。」 |
| 共有コレクションの一覧 | All Users に root とヘルプデスクの write | ヘルプデスクだけが見える |
| ダッシュボードのカードを部署フィルター付きで実行 | 試していない | 2枚とも completed |
この組み方で失うものもあります。create-queries は新しい問い合わせだけでなく、ドリルスルー(グラフをクリックして明細へ降りる操作)にも必要だと公式ドキュメントにあるので、Hanako はダッシュボードを見てフィルターを変える以上のことはできません。自分で集計軸を変えたい人には「query-builder」だけを付けた別グループを作ります。また、OSS版では view-data が全員 unrestricted のままなので、どこかのグループで create-queries を与えると、そのDBで同期したテーブルは全部問い合わせられます。見せたくないテーブルは、Metabase の権限ではなくDBユーザーの GRANT で外してください。結果のダウンロードを禁止する設定も、公式ドキュメントでは有料版だけです。
セキュリティの初期値を実際に確かめる
ここからは、設定項目の説明を読むだけでは分からない部分を叩いて確かめます。最初に、公開と埋め込みに関わる設定の現在値と既定値です。
./mbapi GET /api/setting | jq -c '.[] | select(.key | test("^(enable-public-sharing|enable-embedding-(static|interactive|sdk)|encryption-enabled)$")) | {key, value, default}'{"key":"enable-embedding-interactive","value":null,"default":false}
{"key":"enable-embedding-sdk","value":null,"default":false}
{"key":"enable-embedding-static","value":null,"default":false}
{"key":"enable-public-sharing","value":null,"default":true}
{"key":"encryption-enabled","value":true,"default":null}value が null の項目は既定値が効いています。公開共有(enable-public-sharing)は既定で true、3種類の埋め込みは既定で false でした。encryption-enabled が true なのは、.env で暗号化キーを渡しているからです。
読み取り専用ユーザーはSQLエディタからのUPDATEを止められるか
管理者として、Metabase の SQL エディタと同じ API に UPDATE を2通り送ります。2つ目は、読み取り専用トランザクションの設定をセッション側で外してから UPDATE する書き方です。
cat > update.sql <<'EOF'
UPDATE ops.assets SET status = '廃棄予定' WHERE asset_tag = 'IT-001'
EOF
cat > update-bypass.sql <<'EOF'
SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE; COMMIT;
UPDATE ops.assets SET status = '廃棄予定' WHERE asset_tag = 'IT-001'
EOF
for f in update.sql update-bypass.sql; do
jq -n --argjson db "$(cat db-id.txt)" --rawfile sql "$f" '{database: $db, type: "native", native: {query: $sql}}' > q-update.json
./mbapi POST /api/dataset @q-update.json | jq -c --arg f "$f" '{file: $f, status, error}'
done{"file":"update.sql","status":"failed","error":"ERROR: cannot execute UPDATE in a read-only transaction"}
{"file":"update-bypass.sql","status":"failed","error":"ERROR: permission denied for table assets"}1つ目は default_transaction_read_only で止まり、2つ目はその設定を外せたうえで、UPDATE 権限がないことで止まりました。SQL エディタを使える人なら1段目は外せるので、書き込みを止めているのは GRANT のほうです。
比較のため、書き込み権限のある所有者ユーザー(helpdesk_owner)で一時的な接続を作り、同じ UPDATE を送ります。
install -m 600 /dev/null owner-db.json
jq -n --arg pw "$(grep '^BIZDB_PASSWORD=' ../helpdesk-db/.env | cut -d= -f2-)" '{engine: "postgres", name: "owner-test", details: {host: "bizdb", port: 5432, dbname: "helpdesk", user: "helpdesk_owner", password: $pw, ssl: false, "schema-filters-type": "inclusion", "schema-filters-patterns": "ops"}}' > owner-db.json
./mbapi POST /api/database @owner-db.json | jq .id | tee owner-db-id.txt
jq -n --argjson db "$(cat owner-db-id.txt)" --rawfile sql update.sql '{database: $db, type: "native", native: {query: $sql}}' > q-update.json
./mbapi POST /api/dataset @q-update.json | jq -c '{status, error}'2
{"status":"failed","error":"SELECTステートメントがネイティブクエリのResultSetを生成しませんでした"}応答は「ResultSet を生成しませんでした」という失敗でしたが、業務DBを見ると値は変わっていました。
cd ~/helpdesk-db
docker compose exec -T bizdb psql -U helpdesk_owner -d helpdesk -c "SELECT asset_tag, status FROM ops.assets WHERE asset_tag = 'IT-001'"
docker compose exec -T bizdb psql -U helpdesk_owner -d helpdesk -c "UPDATE ops.assets SET status = '稼働中' WHERE asset_tag = 'IT-001'"
cd ~/metabase
./mbapi DELETE "/api/database/$(cat owner-db-id.txt)"
rm owner-db.json owner-db-id.txt
./mbapi GET /api/database | jq -c '[.data[] | {id, name}]' asset_tag | status
-----------+----------
IT-001 | 廃棄予定
(1 row)
UPDATE 1
[{"id":1,"name":"ヘルプデスクDB"}]v0.64.3.4 の Metabase は、書き込める接続で送られた UPDATE を自分では止めず、画面には失敗のように見える応答を返しました。「エラーが出たから何も起きていない」と思い込むと、業務データが黙って書き換わります。値は戻し、一時的な接続は消しました(DB一覧にはヘルプデスクDBだけが残っています)。
読み取り専用にすると使えなくなる機能もあります。CSV をアップロードしてテーブルにする機能の保存先にこのDBを指定すると、アップロードは権限エラーになりました。
./mbapi PUT /api/setting/uploads-settings "{\"value\": {\"db_id\": $(cat db-id.txt), \"schema_name\": \"ops\", \"table_prefix\": null}}"
printf 'asset_tag,memo\nIT-001,uploaded\n' > upload-test.csv
curl -s -X POST -H "X-Metabase-Session: $(cat session.txt)" -F "collection_id=$(cat collection-id.txt)" -F [email protected] http://127.0.0.1:13300/api/upload/csv; echo
./mbapi PUT /api/setting/uploads-settings '{"value": {"db_id": null, "schema_name": null, "table_prefix": null}}'{"message":"ERROR: permission denied for schema ops\n Position: 14"}アップロードを使いたい場合は、業務データとは別のスキーマと、そこにだけ書き込める別ユーザーの接続を用意することになります(この構成は試していません)。
公開リンクを作ると、ログインなしで何が見えるか
公開共有が既定で true なので、管理者は何も設定を変えずにダッシュボードの公開リンクを作れます。作ったリンクのUUIDだけで、セッションなしに何が取れるかを試します。
./mbapi POST "/api/dashboard/$(cat dashboard-id.txt)/public_link" | jq -r .uuid > public-uuid.txt
dc=$(./mbapi GET "/api/dashboard/$(cat dashboard-id.txt)" | jq --argjson c "$(cat card-open-id.txt)" '.dashcards[] | select(.card_id == $c) | .id')
curl -s "http://127.0.0.1:13300/api/public/dashboard/$(cat public-uuid.txt)" | jq -c '{name, cards: [.dashcards[].card.name]}'
curl -s "http://127.0.0.1:13300/api/public/dashboard/$(cat public-uuid.txt)/dashcard/$dc/card/$(cat card-open-id.txt)" | jq -c '.data.rows'{"name":"ヘルプデスク概況","cards":["未完了チケット(カテゴリ別)","週別の受付件数と完了件数"]}
[["アカウント",15],["業務システム",10],["PC・周辺機器",8],["ネットワーク",8],["その他",2]]ダッシュボード名、カード名に加えて、フィルターを掛けていない全部署の集計結果がログインなしで返りました。つまり、Metabase に届く経路さえあれば、アカウントを持たない人でも URL だけで取れる状態です。公開リンクの閲覧にサインインは要らず、URL のパラメーターを外せばフィルター前の内容が見える、と公式ドキュメント自身が注意しています。
公開共有をオフにすれば済むかを試します。
./mbapi PUT /api/setting/enable-public-sharing '{"value": false}'
curl -s -o /dev/null -w 'off: %{http_code}\n' "http://127.0.0.1:13300/api/public/dashboard/$(cat public-uuid.txt)"
./mbapi PUT /api/setting/enable-public-sharing '{"value": true}'
curl -s -o /dev/null -w 'on again: %{http_code}\n' "http://127.0.0.1:13300/api/public/dashboard/$(cat public-uuid.txt)"
./mbapi DELETE "/api/dashboard/$(cat dashboard-id.txt)/public_link"
curl -s -o /dev/null -w 'link removed: %{http_code}\n' "http://127.0.0.1:13300/api/public/dashboard/$(cat public-uuid.txt)"off: 400
on again: 200
link removed: 404オフの間は 400 になりますが、オンに戻すと同じリンクが 200 で復活しました。オフは「一時停止」で、リンクの取り消しではありません。取り消すのはリンクの削除(DELETE)で、その後は 404 になりました。
社内向けのダッシュボードで公開リンクを使わないなら、環境変数で固定してしまうのが確実です。compose.yaml の MB_LOAD_SAMPLE_CONTENT の行の下に1行足して、コンテナを作り直します。
sed -i '/MB_LOAD_SAMPLE_CONTENT/a\ MB_ENABLE_PUBLIC_SHARING: "false"' compose.yaml
grep -n 'MB_LOAD_SAMPLE_CONTENT\|MB_ENABLE_PUBLIC_SHARING' compose.yaml
docker compose up -d --wait35: MB_LOAD_SAMPLE_CONTENT: "false"
36: MB_ENABLE_PUBLIC_SHARING: "false"
Container metabase-docker-dashboard-appdb-1 Running
Container metabase-docker-dashboard-metabase-1 Recreate
Container metabase-docker-dashboard-metabase-1 Recreated
Container metabase-docker-dashboard-appdb-1 Waiting
Container metabase-docker-dashboard-appdb-1 Healthy
Container metabase-docker-dashboard-metabase-1 Starting
Container metabase-docker-dashboard-metabase-1 Started
Container metabase-docker-dashboard-appdb-1 Waiting
Container metabase-docker-dashboard-metabase-1 Waiting
Container metabase-docker-dashboard-appdb-1 Healthy
Container metabase-docker-dashboard-metabase-1 Healthycurl -s -o /dev/null -w 'PUT: %{http_code}\n' -X PUT -H "X-Metabase-Session: $(cat session.txt)" -H 'Content-Type: application/json' --data '{"value": true}' http://127.0.0.1:13300/api/setting/enable-public-sharing
./mbapi POST "/api/dashboard/$(cat dashboard-id.txt)/public_link"; echo
./mbapi GET /api/setting | jq -c '.[] | select(.key == "enable-public-sharing") | {key, value, is_env_setting}'PUT: 204
公開共有が有効になっていません。
{"key":"enable-public-sharing","value":null,"is_env_setting":true}環境変数で決めた設定は is_env_setting: true になり、API からオンに変えようとしても効きません。注意したいのは、PUT が 204(成功)を返すことです。API の応答だけを見て「オンにできた」と判断せず、リンクの作成が「公開共有が有効になっていません。」で拒否されることで確かめます。この固定で失うのは、社外向けに公開リンクを出す運用そのものです。
埋め込みを有効にすると何が開くか
静的埋め込みを有効にすると、Metabase は埋め込み用の秘密鍵を作ります。自社アプリのサーバーがこの鍵で JWT に署名し、どの質問を見せるかを指定する仕組みです。鍵を持つ側が何をできるかを確かめます。
./mbapi PUT /api/setting/enable-embedding-static '{"value": true}'
./mbapi PUT "/api/card/$(cat card-open-id.txt)" '{"enable_embedding": true}' | jq -c '{id, enable_embedding}'
install -m 600 /dev/null embed-key.txt
./mbapi GET /api/setting | jq -r '.[] | select(.key == "embedding-secret-key") | .value' > embed-key.txt
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
header=$(printf '{"alg":"HS256","typ":"JWT"}' | b64url)
payload=$(printf '{"resource":{"question":%s},"params":{},"exp":%s}' "$(cat card-open-id.txt)" "$(( $(date +%s) + 600 ))" | b64url)
sig=$(printf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -hmac "$(cat embed-key.txt)" -binary | b64url)
curl -s "http://127.0.0.1:13300/api/embed/card/$header.$payload.$sig/query" | jq -c '.data.rows'
bad=$(printf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -hmac "guessed-key" -binary | b64url)
curl -s "http://127.0.0.1:13300/api/embed/card/$header.$payload.$bad/query"; echo
./mbapi PUT "/api/card/$(cat card-open-id.txt)" '{"enable_embedding": false}' | jq -c '{id, enable_embedding}'
./mbapi PUT /api/setting/enable-embedding-static '{"value": false}'
curl -s "http://127.0.0.1:13300/api/embed/card/$header.$payload.$sig/query"; echo
rm embed-key.txt{"id":1,"enable_embedding":true}
[["アカウント",15],["業務システム",10],["PC・周辺機器",8],["ネットワーク",8],["その他",2]]
Message seems corrupt or manipulated
{"id":1,"enable_embedding":false}
埋め込みが有効になっていません。秘密鍵で署名したトークンだけで、Metabase のアカウントを持たないままカテゴリ別の集計が取れました。推測した鍵での署名は拒否され、カードの埋め込み許可と静的埋め込みをオフに戻すと、正しい署名でも「埋め込みが有効になっていません。」になりました。埋め込み用の秘密鍵は、埋め込みを許可した全部の質問とダッシュボードを読める鍵です。ブラウザ側のコードには置かず、署名は必ずサーバー側で行います。OSS版の埋め込みには「Powered by Metabase」の表示が付き、外すには Pro か Enterprise が要ります(公式ドキュメント)。
公表されている脆弱性と、この構成で外している前提
脆弱性は、GitHub の metabase/metabase リポジトリのセキュリティアドバイザリで公表されています。執筆時点で2026年に公開されたものには、認証なしのエンドポイント(/api/session/reset_password)からアプリケーションDBへ SQL を注入して管理者権限を得られるもの(GHSA-vwf4-m7j8-wcjf、CVE-2026-72898、悪用を確認済みと記載)、絞り込み用のパラメーターを持つ公開ダッシュボードから同様の注入ができるもの(GHSA-r8h2-qpfx-mx59、CVE-2026-72899)、サンプルDBなど H2 の接続を通じてサーバー上のファイルを読み書きできるもの(GHSA-cwxq-fmxq-jv8h)が並んでいます。公開ダッシュボードの件では、公開共有が既定で有効なため、リンクを1つ公開しているだけで攻撃の前提がそろうと説明されています。別の件(GHSA-8hmm-hrhg-ppqp)では、暗号化キーを設定していないインスタンスだと、接続先DBの資格情報が平文で読み出せたとされています。
これらの修正版は影響を受けた各系列に出ていて、アドバイザリに載っている影響範囲に 0.64 系は含まれていません(執筆時点)。公開リンクを使わない、サンプルDBを入れない、暗号化キーを設定する、ポートを外に出さない、というこの記事の設定は、どれもこの一覧に載った攻撃の前提を1つずつ外すものです。それでも本体の更新は必要なので、アドバイザリの一覧を定期的に確認し、タグを上げて入れ替える手順(次の節のバックアップを取ってから)を運用に組み込みます。
アプリケーションDBをバックアップして別のスタックに戻す
公式ドキュメントのとおり、Metabase の状態(ユーザー、質問、ダッシュボード、権限、接続情報)はすべてアプリケーションDBにあります。この構成では Metabase のコンテナにボリュームがないので、バックアップは appdb のダンプと .env の2つです。PostgreSQL のドキュメントでは、pg_dump は使用中のDBからでも一貫した内容を取り出し、読み書きする他の接続を止めないとされているので、Metabase は止めずに実行しています。同じドキュメントは、単純な場合を除いて本番DBの定期バックアップに pg_dump を勧めていませんが、ダンプが1MB程度のアプリケーションDBはその単純な場合にあたると筆者は判断しています。
install -m 600 /dev/null metabase-appdb.dump
docker compose exec -T appdb pg_dump -U metabase -d metabase -Fc > metabase-appdb.dump
ls -l metabase-appdb.dump-rw------- 1 root root 1067155 Oct 26 03:15 metabase-appdb.dumpダンプは約1.07MBでした。復元の練習は、元の環境を消さずに、別のディレクトリ・別のプロジェクト名・別のポートで行います。compose.yaml は同じもので、.env は鍵とパスワードを元のまま、プロジェクト名とポートだけを変えます。アプリケーションDBだけを先に起動し、Metabase が起動する前に中身を戻すのが順番の要です。先に Metabase が起動すると空のDBにテーブルを作り始めるので、その後のリストアとぶつかります(この順番違いは試していません)。
mkdir -p ~/metabase-restore && cd ~/metabase-restore
cp ../metabase/compose.yaml ../metabase/mbapi ../metabase/dash-check.sh ../metabase/dashboard-id.txt .
install -m 600 /dev/null .env
sed -e 's/^COMPOSE_PROJECT_NAME=.*/COMPOSE_PROJECT_NAME=metabase-docker-dashboard-restore/' -e 's/^MB_PORT=.*/MB_PORT=13301/' ../metabase/.env > .env
docker compose up -d --wait appdb
docker compose exec -T appdb pg_restore -U metabase -d metabase --no-owner --exit-on-error < ../metabase/metabase-appdb.dump
time docker compose up -d --wait Container metabase-docker-dashboard-restore-appdb-1 Running
Container metabase-docker-dashboard-restore-metabase-1 Creating
Container metabase-docker-dashboard-restore-metabase-1 Created
Container metabase-docker-dashboard-restore-appdb-1 Waiting
Container metabase-docker-dashboard-restore-appdb-1 Healthy
Container metabase-docker-dashboard-restore-metabase-1 Starting
Container metabase-docker-dashboard-restore-metabase-1 Started
Container metabase-docker-dashboard-restore-appdb-1 Waiting
Container metabase-docker-dashboard-restore-metabase-1 Waiting
Container metabase-docker-dashboard-restore-appdb-1 Healthy
Container metabase-docker-dashboard-restore-metabase-1 Healthy
real 0m41.427s
user 0m0.121s
sys 0m0.078s復元先で、元の管理者のパスワードでログインし、同じダッシュボードを同じフィルターで実行します。
install -m 600 /dev/null session.txt
jq -n --rawfile pw ../metabase/admin-password.txt '{username: "[email protected]", password: ($pw | rtrimstr("\n"))}' | curl -s -X POST -H 'Content-Type: application/json' --data-binary @- http://127.0.0.1:13301/api/session | jq -r .id > session.txt
./dash-check.sh 開発部
curl -s -o /dev/null -w 'old session on restore: %{http_code}\n' -H "X-Metabase-Session: $(cat ../metabase/session.txt)" http://127.0.0.1:13301/api/user/current{"card":1,"status":"completed","row_count":4,"first":["PC・周辺機器",3]}
{"card":2,"status":"completed","row_count":8,"first":["2026-08-03T00:00:00+09:00",3,2]}
old session on restore: 200ダッシュボードの2枚のカードが、元の環境と同じ件数と先頭行で返りました。業務DBへの接続情報も暗号化されたまま戻り、同じ鍵で復号できています。最後の行は、元の環境で取ったセッションIDを、復元先にそのまま送った結果です。200 が返ったので、ダンプには有効なログインセッションも入っています。
ダンプはパスワードハッシュと有効なセッションを含むので、DBのパスワードと同じ扱いで保管します。ダンプが漏れた疑いがあるときは、MB_SESSION_SECRET_KEY を変えて全員をログアウトさせ(公式ドキュメント)、業務DBの metabase_ro のパスワードも変えます。逆に .env の暗号化キーが元と違うと、Metabase は起動しません(「つまずきポイント」で再現します)。ダンプと .env は別々の場所に、両方とも保管してください。
メモリを実測してmem_limitを決める
ここまでの操作(DB同期、2つの質問、ダッシュボード、権限変更、公開・埋め込みの確認、ダンプ)をした後の使用量です。
cd ~/metabase && docker compose stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}'
cd ~/helpdesk-db && docker compose stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}'
cd ~/metabase && docker compose exec -T metabase java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -w MaxHeapSize
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'metabase/metabase:v0.64.3.4|postgres:18.6'NAME MEM USAGE / LIMIT
metabase-docker-dashboard-metabase-1 1.013GiB / 2GiB
metabase-docker-dashboard-appdb-1 97.32MiB / 256MiB
NAME MEM USAGE / LIMIT
metabase-docker-dashboard-biz-bizdb-1 38.09MiB / 256MiB
size_t MaxHeapSize = 536870912 {product} {ergonomic}
metabase/metabase:v0.64.3.4 1.91GB
postgres:18.6 650MB使用量は、Metabase が上限2GiBに対して1.013GiB、アプリケーションDBは97.32MiB、業務DBは38.09MiBでした。Java のヒープ上限(MaxHeapSize)は 536870912 バイト、つまり512MiBで、mem_limit の4分の1に自動で決まっています(値の横の {ergonomic} が自動決定の印です)。プロセス全体はヒープの約2倍になっているので、mem_limit を絞るとヒープも一緒に縮み、先に Java が音を上げます。少人数でダッシュボードを見る用途なら、2g を出発点にし、利用者や重い質問が増えたら mem_limit ごと上げるのが分かりやすい決め方です。ヒープだけを JAVA_OPTS で指定する方法もありますが、この記事では試していません。イメージは Metabase が1.91GB、PostgreSQL が650MBあるので、ディスクにも余裕を見ておきます。
つまずきポイント
postgres:18 で /var/lib/postgresql/data にボリュームを付けると起動しない
PostgreSQL 17 以前の構成例をそのまま 18 系に使うと、コンテナはすぐ終了します。その場で試すと、ログの先頭はこうなります。
docker run --rm --name metabase-docker-dashboard-pg18test -e POSTGRES_PASSWORD=test -v metabase-docker-dashboard-pg18test:/var/lib/postgresql/data postgres:18.6 2>&1 | head -n 3
docker volume rm metabase-docker-dashboard-pg18testError: in 18+, these Docker images are configured to store database data in a
format which is compatible with "pg_ctlcluster" (specifically, using
major-version-specific directory names). This better reflects how
metabase-docker-dashboard-pg18test18 系の公式イメージは、メジャー版ごとのサブディレクトリにデータを置く前提で、ボリュームを /var/lib/postgresql に付けるよう求めています。この記事の2つの compose.yaml はどちらもそう書いています。
network … declared as external, but could not be found
Metabase のプロジェクトは業務DB側のネットワークに external で参加するので、そのネットワークがないと起動しません。~/helpdesk-db を起動する前に ~/metabase を up したときや、2つの .env で BIZ_NETWORK の値が食い違っているときに起きます。ここでは値を書き間違えた状態を環境変数で再現しました。
BIZ_NETWORK=helpdesk-net-typo docker compose up -dnetwork helpdesk-net-typo declared as external, but could not be founddocker network ls で業務DB側のプロジェクトが作ったネットワーク名を確かめ、.env の BIZ_NETWORK をそれに合わせます。起動の順番は、業務DB、Metabase の順です。止めるときは逆の順番にします(「片付け」の節のとおり)。
mem_limitを1gにするとOutOfMemoryErrorで止まる
メモリを節約しようとして mem_limit を 1g に下げると、Metabase の Java は起動の途中でヒープを使い切って異常終了しました。
sed -i 's/mem_limit: 2g/mem_limit: 1g/' compose.yaml
docker compose up -d metabase
until docker compose logs metabase | grep -q OutOfMemoryError; do sleep 5; done
docker compose logs metabase | grep -m 1 OutOfMemoryError
docker run --rm -m 1g --entrypoint java metabase/metabase:v0.64.3.4 -XX:+PrintFlagsFinal -version 2>/dev/null | grep -w MaxHeapSize Container metabase-docker-dashboard-appdb-1 Running
Container metabase-docker-dashboard-metabase-1 Recreate
Container metabase-docker-dashboard-metabase-1 Recreated
Container metabase-docker-dashboard-appdb-1 Waiting
Container metabase-docker-dashboard-appdb-1 Healthy
Container metabase-docker-dashboard-metabase-1 Starting
Container metabase-docker-dashboard-metabase-1 Started
metabase-1 | Aborting due to java.lang.OutOfMemoryError: Java heap space
size_t MaxHeapSize = 268435456 {product} {ergonomic}1g の上限ではヒープが268435456バイト(256MiB)に決まり、起動処理の途中で使い切りました。2g に戻して作り直すと healthy に戻ります。
sed -i 's/mem_limit: 1g/mem_limit: 2g/' compose.yaml
docker compose up -d --wait
curl -s http://127.0.0.1:13300/api/health; echo Container metabase-docker-dashboard-appdb-1 Running
Container metabase-docker-dashboard-metabase-1 Recreate
Container metabase-docker-dashboard-metabase-1 Recreated
Container metabase-docker-dashboard-appdb-1 Waiting
Container metabase-docker-dashboard-appdb-1 Healthy
Container metabase-docker-dashboard-metabase-1 Starting
Container metabase-docker-dashboard-metabase-1 Started
Container metabase-docker-dashboard-metabase-1 Waiting
Container metabase-docker-dashboard-appdb-1 Waiting
Container metabase-docker-dashboard-appdb-1 Healthy
Container metabase-docker-dashboard-metabase-1 Healthy
{"status":"ok"}復元先で Database was encrypted with a different key
復元先の .env を作り直したときに暗号化キーを新しく生成してしまうと、アプリケーションDBの中身と鍵が合わず、Metabase は初期化に失敗します。復元先の ~/metabase-restore で再現します。
sed -i "s|^MB_ENCRYPTION_SECRET_KEY=.*|MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)|" .env
docker compose up -d metabase
until docker compose logs metabase | grep -q 'different key'; do sleep 5; done
docker compose logs metabase | grep -m 2 -E 'Initialization FAILED|different key' Container metabase-docker-dashboard-restore-appdb-1 Running
Container metabase-docker-dashboard-restore-metabase-1 Recreate
Container metabase-docker-dashboard-restore-metabase-1 Recreated
Container metabase-docker-dashboard-restore-appdb-1 Waiting
Container metabase-docker-dashboard-restore-appdb-1 Healthy
Container metabase-docker-dashboard-restore-metabase-1 Starting
Container metabase-docker-dashboard-restore-metabase-1 Started
metabase-1 | 2026-10-26 12:18:09,906 ERROR core.core :: Metabase Initialization FAILED
metabase-1 | clojure.lang.ExceptionInfo: Database was encrypted with a different key than the MB_ENCRYPTION_SECRET_KEY environment contains {}元の .env から作り直して同じ鍵に戻すと起動します。鍵そのものを失った場合は、接続情報をすべて設定し直すことになります(公式ドキュメント)。
sed -e 's/^COMPOSE_PROJECT_NAME=.*/COMPOSE_PROJECT_NAME=metabase-docker-dashboard-restore/' -e 's/^MB_PORT=.*/MB_PORT=13301/' ../metabase/.env > .env
docker compose up -d --wait
curl -s http://127.0.0.1:13301/api/health; echo Container metabase-docker-dashboard-restore-appdb-1 Running
Container metabase-docker-dashboard-restore-metabase-1 Recreate
Container metabase-docker-dashboard-restore-metabase-1 Recreated
Container metabase-docker-dashboard-restore-appdb-1 Waiting
Container metabase-docker-dashboard-restore-appdb-1 Healthy
Container metabase-docker-dashboard-restore-metabase-1 Starting
Container metabase-docker-dashboard-restore-metabase-1 Started
Container metabase-docker-dashboard-restore-metabase-1 Waiting
Container metabase-docker-dashboard-restore-appdb-1 Waiting
Container metabase-docker-dashboard-restore-appdb-1 Healthy
Container metabase-docker-dashboard-restore-metabase-1 Healthy
{"status":"ok"}動作確認
~/metabase で、ここまでの設定が全部効いていることをまとめて確かめます。部署は「営業部」に変えています。
curl -s http://127.0.0.1:13300/api/health; echo
SESSION_FILE=hanako-session.txt ./dash-check.sh 営業部
SESSION_FILE=hanako-session.txt ./mbapi POST /api/dataset @q-count.json | jq -c '{status, error}'
./mbapi POST "/api/dashboard/$(cat dashboard-id.txt)/public_link"; echo
curl -s http://127.0.0.1:13300/api/session/properties | jq -c '{"setup-token", "has-user-setup"}'{"status":"ok"}
{"card":1,"status":"completed","row_count":5,"first":["アカウント",5]}
{"card":2,"status":"completed","row_count":12,"first":["2026-08-03T00:00:00+09:00",4,3]}
{"status":"failed","error":"このクエリを実行する権限がありません。"}
公開共有が有効になっていません。
{"setup-token":null,"has-user-setup":true}期待どおりの結果は次のとおりです。
- 1行目:
{"status":"ok"}。本体が起動して応答している。 - 2〜3行目:Hanako のセッションで、ダッシュボードの2枚が営業部に絞って completed で返る(1枚目は5カテゴリで先頭がアカウント5件、2枚目は12週)。
- 4行目:Hanako の任意の SQL は「このクエリを実行する権限がありません。」で拒否される。
- 5行目:管理者でも公開リンクを作れない(環境変数で固定した結果)。
- 6行目:
setup-tokenが null で、初期設定はやり直せない。
この記事の確認はすべて API の応答で行い、画面の操作は記録していません。ブラウザで見るときは、サーバー上で http://127.0.0.1:13300/ を開き、管理者のメールアドレスと admin-password.txt の中身でログインします。ポートを 127.0.0.1 に限っているので、別のPCから見る場合はリバースプロキシなどの経路を別に用意してください。
MetabaseとRedash・Superset・Excel・Streamlitの使い分け
ここまで動かした結果から、筆者がツールを選ぶときの基準をまとめます。なお、Metabase 以外の列は、この記事では動かしていないツールについての一般的な位置付けです。
| 選択肢 | 集計を作る人 | 運用に要るもの | 見せ分けの単位 | 向く場面 | 向かない場面 |
|---|---|---|---|---|---|
| Metabase(OSS版) | SQLを書かない担当者も、画面のクエリビルダーで作れる | Java の本体(今回1.013GiB)とアプリケーションDB、鍵を含むバックアップ | グループ×コレクション、DB単位のクエリ可否。行と列の制限は有料版 | 部署別の定型ダッシュボードを社内の数十人に見せる | 部署ごとに自分の行だけを見せる必要がある |
| Redash | SQLを書ける人が中心 | サーバーとメタデータ用DB | クエリとダッシュボードの共有 | SQLを書くメンバーが結果を共有する | SQLを書かない人が自分で集計を変える |
| Apache Superset | SQLを書ける人と、可視化を作り込む人 | 構成要素が多く、専任の運用担当がほしい | ロールと細かい権限設定 | グラフの種類や権限を作り込む組織 | ひとり情シスで片手間に回す |
| Excel(共有ブック) | 担当者1人 | 追加の基盤なし | ファイルの共有範囲 | 月1回の報告、手で直すデータ | 毎日更新、閲覧者が多い |
| Streamlit などの小さな自作アプリ | Python を書く人 | Python の実行環境とコードの保守 | アプリ側で作り込み | 入力フォームや計算を伴う社内ツール | 集計の切り口を利用者が自分で変える |
判断の順番は次のとおりです。まず、部署ごとに自分の部署の行だけを見せる必要があるかを確かめます。必要なら、OSS版の Metabase ではDB側で部署ごとの接続ユーザーやビューを分けるしかなく、管理が重くなるので、有料版か別の仕組みを検討します。不要で、SQLを書かない人が自分でグラフを作り替えたいなら Metabase、作るのがSQLを書ける人だけなら Redash や Metabase の SQL 質問で足ります。入力や計算を伴うなら BI ではなく、Streamlitで作る社内向けWebアプリのような小さなアプリが向きます。どれも運用する人がいないなら、Excel で回せるうちは Excel のほうが事故が少なく済みます。
片付け
試し終えたら、作った順の逆にプロジェクトを止め、ボリュームとイメージを消します。down -v はアプリケーションDBのボリュームも消すので、残したい環境で実行しないでください。
cd ~/metabase-restore && docker compose down -v
cd ~/metabase && docker compose down -v
cd ~/helpdesk-db && docker compose down -v
docker rmi metabase/metabase:v0.64.3.4 postgres:18.6 Container metabase-docker-dashboard-biz-bizdb-1 Stopping
Container metabase-docker-dashboard-biz-bizdb-1 Stopped
Container metabase-docker-dashboard-biz-bizdb-1 Removing
Container metabase-docker-dashboard-biz-bizdb-1 Removed
Volume metabase-docker-dashboard-biz_bizdb-data Removing
Network metabase-docker-dashboard-biznet Removing
Volume metabase-docker-dashboard-biz_bizdb-data Removed
Network metabase-docker-dashboard-biznet Removed
Untagged: metabase/metabase:v0.64.3.4
Deleted: sha256:7804ce4edfa217dd08a896821f57b12a9a392d944bd3797a55520d51c76232e3
Untagged: postgres:18.6
Deleted: sha256:edb0c08dc33befd0262ab3b5cf1c4f3f8cfd8b09e88c05b45adee29d6761ddb6よくある質問
Metabaseの特徴は何ですか?
SQLを書かない人向けのクエリビルダーと、SQLを書く人向けのエディタの両方で質問を作り、ダッシュボードとコレクションで共有できる点です。設定はすべてアプリケーションDBに入るので、今回の検証ではダンプ1つと鍵を入れた .env だけで、同じサーバー上の別スタックにダッシュボードごと戻せました。
Metabaseはどうやってインストールしますか?
Metabase が Docker Hub で配布しているイメージ metabase/metabase を使うのが手早い方法です。そのままではコンテナ内の H2 に設定が保存されるので、公式ドキュメントの推奨どおり PostgreSQL をアプリケーションDBにし、暗号化キーを渡し、ポートは 127.0.0.1 に限って起動します。この記事の compose.yaml がその最小構成です。
Metabaseは商用利用できますか?
オープンソース版は GNU Affero General Public License(AGPL)で公開されています(執筆時点、Metabase のライセンスページ)。自社アプリへの埋め込みや改変して提供する場合は、AGPL に従うか、Embedding License や商用ライセンスを選ぶ形になります。利用形態ごとの扱いは、ライセンスページを読んだうえで法務に確認してください。
メタベースはいくらですか?
オープンソース版は無料で、費用はサーバーと運用の手間です(今回の構成で Metabase のメモリは約1GiB)。執筆時点の料金ページでは、Pro が月額575ドルで10ユーザー込み、追加は1ユーザー月12ドル、Enterprise は年額20,000ドルからで、いずれも税別でした。変わりやすいので公式の料金ページで確認してください。
まとめ
業務DBは読み取り専用ユーザーでつなぎ、書き込みを止めているのが GRANT であることを確かめました。一般ユーザーには create-queries を外してコレクションの read だけを与え、公開リンクは環境変数で無効に固定し、埋め込みの秘密鍵はサーバーの外に出しません。バックアップはアプリケーションDBのダンプと鍵の入った .env の2点で、別スタックに戻すとダッシュボードが同じ値で返りました。次は自分の業務DBに metabase_ro と同じ権限のユーザーを作り、この compose.yaml の .env だけを書き換えて、まず1枚のダッシュボードを閲覧グループに見せてみてください。