認証のないセルフホストS3を、コードに手を入れず安全に公開する — DS918+ × Garage × Cloudflare Tunnel × Access
はじめに
Synology DS918+ に S3互換オブジェクトストレージ Garage を Docker Compose で立て、Cloudflare Tunnel(cloudflared) で外部公開し、Cloudflare Access でアクセス制御した構成をまとめます。コピペで再現できることを目指しました。
この記事の肝は、最後の「守り方」です。
- 自宅 NAS のポートをルーターに一切開けない(Tunnel の outbound 接続だけで公開)
- S3 API と 管理UI を、同じトンネルから別ホスト名で出し分ける
- そして両者で Cloudflare Access の役割を意図的に変える:
- S3 API は Garage 自身が S3署名(SigV4)認証を持つ → Access は許可IPを**素通り(Bypass)**させ、認証は Garage に任せる
- 管理UI(garage-webui) は アプリ自体に認証が無い → Access が認証本体になり、許可IP かつ SSO ログイン(Allow) の二重で締める
特に管理UIのように 認証機構を持たないセルフホストアプリを、ソースに手を入れず Zero Trust で外付け認証するのは、Cloudflare Access の最も教科書的な使い方です。一方 S3 API 側は、Garage 自身が署名認証を持つので、Access は「到達元IPを絞る門番」に徹します(詳細は後述)。
なぜ MinIO ではなく Garage か
定番の MinIO ではなく Garage を選んだのには、軽さ以上の理由があります。
2025年5月のリリース(2025-05-24 系)以降、MinIO はコミュニティ版の Web コンソールから管理機能を整理しました。 アカウント・ポリシー管理、設定、バケット管理などがブラウザUIから外れ、コンソールは実質「オブジェクトブラウザ」に近い形になり、これらの操作は mc(コマンドラインクライアント)で行う流れになりました。あわせて、コンソールの外部IDPログイン(LDAP / OIDC)はコミュニティ側から外れ、有償の AIStor 側の機能という整理になっています(STS API など、プログラムから組む余地は残っているとされます)。GUIで管理したい場合の選択肢は、AIStor へ移行するか、管理UIが残る最後のバージョン(2025-04-22 系)に固定して更新を止めるか、OpenMaxIO のようなコミュニティフォークやサードパーティUIに頼るか、のいずれか、という状況です。
出典: MinIO 公式リポジトリの議論(discussion #21326、#21316)および各種報道。バージョンや挙動は変わり得るので、採用前に最新の状況を確認してください。
この「管理機能や認証が製品提供側の都合で出し入れされる」流れは、本記事の設計とも噛み合いません。後述するとおり、本構成は アプリ自身の認証機能に依存せず、認証を Cloudflare Access(Zero Trust)側で握る方針だからです。アプリのUIや認証仕様が変わっても、前段の守りは揺らぎません。
その上で Garage の良さは、
- Rust製・単一バイナリで軽量、NAS規模のセルフホストに向く
- 設定が TOML 一枚で完結
- 分散指向だが単一ノードでも素直に動く
- 管理は CLI が基本(GUIはサードパーティの garage-webui を使う) — もともと「UIに依存しない」設計なので、UI仕様変更に振り回されない
全体構成
┌─────────────────────────── Cloudflare ───────────────────────────┐
[クライアント] │ │
│ https://s3.example.com │ s3.example.com ── Access: 許可IPを Bypass ──┐ │
│ (S3署名 / presigned) │ │ │
▼ │ s3-admin.example.com ── Access: 許可IP + SSO(Allow)┤ │
[Cloudflare Edge] ◀───────────┤ │ │
└────────────────────────────────────────────────────┼───────────────┘
│ (outbound tunnel)
▼
┌──────── Synology DS918+ ────────┐
│ cloudflared (container) │
│ ├─ → 192.0.2.10:3900 (S3 API)│
│ └─ → 192.0.2.10:3909 (WebUI) │
│ garage (container) :3900/3903 │
│ garage-webui (container) :3909 │
└──────────────────────────────────┘
別ホスト(クラウド) : portal.example.com (Bearer TOKEN) ── Garage の access/secret key を保持し presigned URL(10分)を発行
ポイントは2つ。
- cloudflared は1コンテナ。1本のトンネルに Public Hostname を2本(S3用 / 管理用)生やして出し分ける。
-
守りの段:
- Cloudflare Access(S3=許可IPを Bypass する門番 / 管理=許可IP + SSO の認証本体)
- Garage の S3署名(private bucket、匿名は 403) ← S3 API を実際に守る主体
- (応用)自前 portal API が Bearer 認証の先で presigned URL を発行 → クライアントに生キーを渡さない
前提
- DSM 7.x、Container Manager 導入済み
- SSH 有効化済み
- 独自ドメインを Cloudflare で管理済み(proxied)
- Cloudflare Zero Trust(無料枠で可)
- 固定グローバルIP(許可IP方式を恒久運用するため。動的IPだと後述のハマりが出る)
- 外部 Docker ネットワーク
qi-netを作成済み
sudo docker network create qi-net
1. ディレクトリ構成
sudo mkdir -p /volume1/portal/garage/{meta,data,conf}
sudo mkdir -p /volume1/portal/garage-container
sudo mkdir -p /volume1/portal/cloudflared-container
2. Garage の設定ファイル
RPC シークレットと管理APIトークンを生成します。
openssl rand -hex 32 # rpc_secret 用
openssl rand -hex 32 # admin_token 用
/volume1/portal/garage/conf/garage.toml:
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "(openssl で生成した64文字hex)"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.example.com"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.example.com"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "(openssl で生成した64文字hex)"
[admin]のadmin_tokenは garage-webui が管理API(3903)へ接続するのに使います。
replication_factor = 1は単一ノード構成なので、Garage 自体による冗長化はありません。 これは Garage 公式も「single-node は冗長性がなく production には推奨しない」と注意している点です。NAS の RAID / スナップショット / Hyper Backup / 別拠点バックアップとは別問題です。本記事は「外部から安全に S3互換 API と管理UI に到達する構成」であって、データ保全設計そのものではない、と理解してください。
3. docker-compose.yml(Garage + WebUI)
/volume1/portal/garage-container/docker-compose.yml:
version: "3.7"
services:
garage:
image: dxflrs/garage:v2.1.0
container_name: garage
restart: unless-stopped
networks:
- qi-net
ports:
# cloudflared を host IP 経由で繋ぐので S3 API だけ host publish。
# 後述「より安全に」のとおり、cloudflared を qi-net に入れれば
# これも publish 不要にできる。
- "3900:3900" # S3 API
expose:
- "3901" # RPC: Docker network 内のみ。LAN に出さない
- "3902" # S3 web: 使わないなら出さない
- "3903" # admin API: webui からだけ使う。LAN に出さない
volumes:
- /volume1/portal/garage/meta:/var/lib/garage/meta
- /volume1/portal/garage/data:/var/lib/garage/data
- /volume1/portal/garage/conf/garage.toml:/etc/garage.toml:ro
command: /garage server
garage-webui:
image: khairul169/garage-webui:latest
container_name: garage-webui
restart: unless-stopped
networks:
- qi-net
ports:
- "3909:3909"
environment:
API_BASE_URL: "http://garage:3903" # qi-net 内でコンテナ名解決
S3_ENDPOINT_URL: "http://garage:3900"
API_ADMIN_KEY: "(garage.toml の admin_token と同じ値)"
# defense in depth: Cloudflare Access に加えて WebUI 側にも簡易認証を置く
# htpasswd -nbBC 10 "USER" "PASS" で生成したハッシュを設定
# AUTH_USER_PASS: "admin:$2y$10$<bcrypt-hash>"
volumes:
- /volume1/portal/garage/conf/garage.toml:/etc/garage.toml:ro
depends_on:
- garage
networks:
qi-net:
external: true
LAN 側公開に注意。
ports:で publish したポートは、インターネットには出ていなくても Synology の LAN 側からは見えます。RPC(3901)/ S3 web(3902)/ admin API(3903)はexpose(Docker ネットワーク内のみ)に留め、LAN に publish しないのが安全です。webui は同じqi-net内なのでhttp://garage:3903で admin API に届き、ホスト側に 3903 を出す必要はありません。さらに堅くするなら、cloudflared も
qi-netに入れて、Public Hostname の Service をhttp://garage:3900/http://garage-webui:3909に向ければ、S3 API の 3900 すら host publish せずに済みます(その場合 §6 の Service を LAN IP からコンテナ名に変更)。
ハマりどころ:
API_ADMIN_KEYは garage.toml の[admin] admin_tokenと完全一致させること。ズレると webui が 401 で管理APIに繋がりません。
起動:
cd /volume1/portal/garage-container
sudo docker compose up -d
4. ノード初期化(レイアウト割り当て)
sudo docker exec -it garage /garage status
表示されたノードIDにレイアウトを割り当てます。
sudo docker exec -it garage /garage layout assign -z dc1 -c 1G <node_id>
sudo docker exec -it garage /garage layout apply --version 1
CLI のサブコマンドや引数(特に
layout assignのオプション)は Garage のバージョンで変わります。本記事はv2.1.0系の例です。お使いのバージョンのgarage --help/garage layout --helpで確認してください。
5. アクセスキーとバケット作成
CLI でもできますが、後述の garage-webui からも GUI で管理できます。
sudo docker exec -it garage /garage key create my-key
sudo docker exec -it garage /garage bucket create my-bucket
sudo docker exec -it garage /garage bucket allow \
--read --write --owner my-bucket --key my-key
key create で出る Key ID / Secret key を控えます(Secret は再表示不可)。この鍵が S3 アクセス制限の実体です。
6. cloudflared を1コンテナで立てる
/volume1/portal/cloudflared-container/docker-compose.yml:
services:
cloudflare-tunnel:
image: cloudflare/cloudflared:latest
container_name: cloudflare-tunnel
restart: unless-stopped
command: tunnel --no-autoupdate run --token ${CF_TUNNEL_TOKEN}
.env:
echo "CF_TUNNEL_TOKEN=(ダッシュボードのトークン)" > .env
chmod 600 .env
トークン方式では ingress ルート(どのホスト名→どのサービス)はダッシュボード側に保存されます。compose には書きません。
Public Hostname を2本生やす
Cloudflare Zero Trust → Networks → Tunnels → 該当トンネル → Public Hostname。
このトンネル1本に、ルートを2本登録します。
| # | Hostname | Service |
|---|---|---|
| 1 | s3-admin.example.com |
http://192.0.2.10:3909 |
| 2 | s3.example.com |
http://192.0.2.10:3900 |
Service は
192.0.2.10(= Synology の LAN IP)直指定にしています。コンテナ名(http://garage:3900)解決でも動きますが、その場合は cloudflared を同じqi-netに載せる必要があります。ホストIP直指定なら cloudflared を別ネットワークのまま運用できます(本構成はこちら)。
.env を置いたら起動:
cd /volume1/portal/cloudflared-container
sudo docker compose up -d
ダッシュボードでトンネルが HEALTHY になればOK。
7. Cloudflare Access でアクセス制御(本記事の核心)
ここからが本題です。S3 API と 管理UI で、Access の役割を変えます。
7-1. S3 API(s3.example.com)= 許可IPを Bypass
S3 API 側は Garage 自身が S3署名(SigV4)認証を持っているので、Access には認証させません。やることは「許可IPだけ通し、その先は Garage の署名に任せる」だけ。
Zero Trust → Access → Applications → Add an application → Self-hosted。
- 宛先(Public Hostname):
s3.example.com - ポリシー: Action = Bypass、ルール = 送信元IP(
203.0.113.10/32など固定IP)
ここが今回いちばんハマった点。 ポリシーの Action を Allow にすると、IPが一致してもログイン(SSO)が要求され、
curlや presigned 直DL が 302 でログイン画面に飛ばされます。
- Allow = 条件に合う人を通すが ログインは要る
- Bypass = 条件(IP/CIDR)に合えば ログイン無しで素通り
presigned URL での自動DL を通したいなら、S3 側は Bypass が正解。Bypass は Include にネットワークセレクタ(IP/CIDR)等しか混ぜられない制約があるので、IP単独ルールにします。
これで挙動はこうなります(実測):
| アクセス | 結果 |
|---|---|
| 許可IP・無認証で素のパス |
403 AccessDenied(Garage の署名ゲートが効く。匿名拒否) |
| 許可IP・presigned URL |
200(正常DL) |
| 許可外IP |
302 → Cloudflare Access のSSOログインへ |
「Access の 302 が消えて Garage の 403 が返る」=「Access を通過して Garage 本体まで到達した」の合図です。動作確認の判定に使えます。
Bypass の正確な意味を押さえておく。 Cloudflare 公式は Bypass について「Access の enforcement を無効化する」「Access のセキュリティ制御を適用しない」「リクエストはログに記録されない」と説明しています。つまり Bypass にした S3 側は、Access で「認証」しているわけではありません。 許可IPを Access の認証フローから外しているだけで、Access の認証・監査ログ・ユーザー識別は効きません。
この構成で S3 API を守っている主体は、あくまで Garage の SigV4 署名と private bucket です。Access の Bypass は「到達元IPを絞る外側の粗い門番」として使っているにすぎません。Cloudflare 公式も、内部アプリへ恒久的に直接アクセスを与える用途での Bypass を積極的には推奨していないので、「Access で守っている」と過信しないことが大事です。ログインなしで「制御」と「監査ログ」を維持したいなら、Bypass ではなく Service Auth(サービストークン)や mTLS を検討してください。サービストークン(
CF-Access-Client-Id/CF-Access-Client-Secretヘッダ)なら、IPに依存せず・Access のログも残しつつ、自動化クライアントを通せます。固定IPが使えない/監査ログが要る恒久運用では、こちらのほうが筋が良い場面があります。
7-2. 管理UI(s3-admin.example.com)= 許可IP + SSO(Allow)
Garage の管理UI(garage-webui)は、未設定のままだと認証なしで起動できます。 素のままトンネルで出すと、URLを知っていれば誰でもバケットやキーを操作できてしまう。
そこで 認証層を Cloudflare Access に肩代わりさせます。(garage-webui 自身も AUTH_USER_PASS で簡易認証を足せるので、defense in depth として両方を併用するのが理想です。後述の compose 参照)
- 宛先:
s3-admin.example.com - ポリシー: Action = Allow、ルール = 送信元IP(許可IP)かつ 特定ユーザー(メールアドレス等で固定)
- 認証: IDプロバイダ(SSO)を有効化、セッション期間は適宜(例: 24h)
S3 側と違い、ここは Bypass ではなく Allow にします。理由は明確で、
- webui は 鍵そのものを発行・閲覧・削除できる画面なので、最も強く守るべき
- IPだけ(Bypass)だと、許可IP圏内なら誰でもログイン無しで管理画面に入れてしまう
- 「許可IP かつ SSOで特定ユーザー」の二重にして初めて安心
設計の対比(この記事で一番言いたいこと)
| S3 API (3900) | 管理UI (3909) | |
|---|---|---|
| アプリ自身の認証 | あり(S3署名) | 既定で無(AUTH_USER_PASSで追加可) |
| Cloudflare Access の役割 | 素通り役(許可IPを通すだけ) | 認証本体 |
| Action | Bypass | Allow |
| 守りの主体 | Garage の SigV4 | Cloudflare Access の SSO |
「アプリが認証を持つか持たないか」で Access の使い方を変える — これが本構成の設計判断です。認証のないセルフホストアプリでも、ソースに一切手を入れず、Access のポリシーだけで安全に公開できます。
8. 動作確認(aws-cli)
aws configure set aws_access_key_id <Key ID>
aws configure set aws_secret_access_key <Secret>
aws configure set default.s3.addressing_style path
aws --endpoint-url https://s3.example.com --region garage s3 ls
echo hello > test.txt
aws --endpoint-url https://s3.example.com s3 cp test.txt s3://my-bucket/
許可IPから正しい鍵で操作でき、鍵なし/誤った鍵では 403 になることを確認できれば完成です。
9.(応用)生キーを配らない — portal API で presigned URL を発行する
ここまでで「許可IP + 署名」の公開 S3 は完成です。さらに一歩進めて、クライアントに access/secret key を一切渡さない運用にできます。
別ホスト(クラウド側)に自前の API サーバを置き、
- クライアントは API に Bearer TOKEN で認証してファイルIDを要求
- API は Garage の access/secret key をサーバ側だけで保持し、**presigned URL(有効期限10分)**を生成して返す
- クライアントはその presigned URL で Garage へ直接DL(
s3.example.com)
[クライアント] ──Bearer──▶ [portal API(別ホスト)] ──access/secret key を保持──▶ presigned URL 発行
│ │
└────────────── 受け取った presigned URL(10分)で Garage へ直DL ─────────────┘
- 生キーがクライアントに出ない(漏れても困るのは10分有効の署名URLだけ)
- portal の入口は Bearer で守られ、Garage 本体は署名必須・匿名403
重要な落とし穴 — presigned 直DLは「クライアント自身のIP」が Access 条件を通る必要がある。
S3 側の Access ポリシーが「許可IPのみ Bypass」の場合、presigned URL を受け取って実際にアクセスするクライアントの送信元IPが許可IPに入っていないと、s3.example.comへの直アクセスは Cloudflare 側で 302(SSO)に弾かれます。
つまり API サーバの固定IPを許可しただけでは「クライアント直DL」は成立しません。 API サーバが許可IPなのは、API サーバ自身が Garage に到達できる(presigned の発行検証や代理取得ができる)という意味であって、別IPの外部クライアントが直DLできることは意味しません。外部クライアントにも配るなら、選択肢は次のいずれか:
- portal API がファイルを proxy 配信する(クライアントは Garage に直アクセスせず、API 経由で受け取る)
- クライアント側を VPN / 固定IP / Access 認証済み にして、Access 条件を満たさせる
- S3 エンドポイントを SigV4 のみで公開(Access の IP 制限を外し、Garage の署名だけで守る)
本構成では「許可IP圏内のクライアント(自宅・拠点・API サーバ)」が前提です。不特定多数の外部に配るなら proxy 配信を選びます。
注意: presigned URL は短命とはいえ、それ自体が bearer credential(持っているだけで使える鍵)です。ただし本構成では Cloudflare Access の Bypass 条件(許可IP)も同時に満たす必要があるため、「URLを持てば誰でもDLできる」のではなく、「許可IP・Access認証済みブラウザ・proxy API など、Cloudflare 側を通過できる相手ならDLできる」と捉えてください。いずれにせよチャットやログに貼らないこと。
ハマりどころまとめ
- Access の Allow と Bypass の取り違え: S3 の自動DL(presigned/curl)を通すなら Bypass。Allow だと IP一致でも SSO ログインを要求され 302。今回いちばんの罠。
- 管理UIを Bypass にしない: webui は認証が無いので、IPだけの Bypass は危険。Allow + SSO + ユーザー固定で締める。
-
API_ADMIN_KEYとadmin_token不一致: webui が 3903 に繋がらない原因の筆頭。 -
path-style 強制: 仮想ホスト形式はワイルドカード証明書が絡むので、クライアントは path-style が無難(
default.s3.addressing_style path)。 - Cloudflare のアップロード上限: Cloudflare 経由のリクエストにはプランごとのボディ上限があり、Free / Pro は 1リクエストあたり約100MB(Business 200MB、Enterprise 500MB+)が目安です。これを超える PUT は前段で 413 で弾かれるので、大容量は S3 マルチパートアップロードを使い、各 part もこの上限未満に刻みます(ダウンロードはこの制限とは別の話)。
- 動的IPだと許可IP方式が崩れる: 許可IPは固定IP前提。動的IPだと再接続でIPが変わり、Bypassルールとズレて 302 に戻る。恒久運用は固定IP / サービストークン / API代理 のいずれかで。
- RPC(3901)/admin(3903)を外に晒さない: トンネルに生やすのは S3(3900)と webui(3909)だけ。admin/RPC は宅内限定に。
まとめ
DS918+ にルーターのポートを一切開けず、cloudflared 1コンテナの1トンネルから S3 API と管理UI を別ホスト名で出し分け、Cloudflare Access で
- S3 API は許可IPを Bypass(認証は Garage の署名に任せる)
- 認証のない管理UIは Allow + SSO(Access が認証本体)
という役割分担で守りました。「アプリが認証を持つかどうかで Zero Trust の使い方を変える」という設計が、そのまま再利用できる形になっています。restic / rclone のバックアップ先としても実用的です。