Cloudflare Tunnelで学術サーバを安全に公開する
背景
学術研究用のサーバでElasticsearch(全文検索)やCantaloupe(IIIF画像配信)を運用する場合、通常はサーバのポートを外部に公開する必要がある。しかしポートを開放すると、脆弱性を突いた攻撃のリスクが生まれる。
Cloudflare Tunnelを使えば、サーバのインバウンドポートを一切開けずに、サービスを安全に外部公開できる。
Cloudflare Tunnelとは
通常のサーバ公開では、サーバ側がポートを開けて外部からの接続を待ち受ける(インバウンド接続)。Cloudflare Tunnelはこの構造を逆転させる。
【従来】
外部 → (80/443ポート) → サーバ
※ サーバがポートを開けて待ち受ける
【Cloudflare Tunnel】
外部 → Cloudflare → ← サーバ(cloudflared)
※ サーバ側からCloudflareに接続しに行く(アウトバウンド)
※ インバウンドポートは不要
サーバ上で動作するcloudflaredというエージェントが、Cloudflareに対してアウトバウンド接続を維持する。外部からのリクエストはCloudflareが受け取り、このトンネル経由でサーバに転送される。
メリット
- ポート開放不要: インバウンドポートを全て閉じられる
- WAF・DDoS防御: Cloudflareが自動で攻撃を吸収
- SSL自動化: Let’s Encryptの設定やリバースプロキシ(Traefik等)が不要
- 無料: Tunnelは無料プランで利用可能
構成
Cloudflare
├── iiif-cf.example.jp → Cantaloupe (8182)
└── es-cf.example.jp → Elasticsearch (9200)
│
│ Tunnel(暗号化済み)
│
サーバ(Docker)
├── cloudflared(Tunnelエージェント)
├── elasticsearch(全文検索)
└── cantaloupe(IIIF画像配信)
手順
1. Cloudflareにドメインを登録
Cloudflareのダッシュボードでドメインを追加し、レジストラ(お名前.com等)のネームサーバをCloudflareに変更する。
2. Tunnelの作成
Cloudflareダッシュボード(Zero Trust → Networks → Tunnels)からTunnelを作成し、トークンを取得する。
CLIでも作成可能:
cloudflared tunnel create my-tunnel
3. Docker Compose
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: always
command: tunnel --protocol http2 run
environment:
TUNNEL_TOKEN: <取得したトークン>
extra_hosts:
- host.docker.internal:host-gateway
networks:
- tunnel-network
elasticsearch:
image: elasticsearch:8.17.0
container_name: elasticsearch
restart: always
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
volumes:
- es-data:/usr/share/elasticsearch/data
networks:
- tunnel-network
cantaloupe:
image: mitlibraries/cantaloupe:latest
container_name: cantaloupe
restart: always
volumes:
- cantaloupe-images:/imageroot
networks:
- tunnel-network
volumes:
es-data:
cantaloupe-images:
networks:
tunnel-network:
ポイント:
- 各サービスはポートを外部に公開しない(
ports指定なし) cloudflaredがDocker内部ネットワーク経由で各サービスにアクセス--protocol http2はUDP制限のある環境で必要
4. DNSルーティングの設定
cloudflared tunnel route dns my-tunnel iiif-cf.example.jp
cloudflared tunnel route dns my-tunnel es-cf.example.jp
5. Ingressルールの設定
Cloudflare APIでホスト名とサービスの紐づけを行う:
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel/<TUNNEL_ID>/configurations" \
-H "X-Auth-Email: <EMAIL>" \
-H "X-Auth-Key: <API_KEY>" \
-H "Content-Type: application/json" \
--data '{
"config": {
"ingress": [
{"hostname": "iiif-cf.example.jp", "service": "http://cantaloupe:8182"},
{"hostname": "es-cf.example.jp", "service": "http://elasticsearch:9200"},
{"service": "http_status:404"}
]
}
}'
最後のhttp_status:404はキャッチオールルール(必須)。
6. 起動・確認
docker compose up -d
curl https://es-cf.example.jp
# → Elasticsearchの応答が返る
curl https://iiif-cf.example.jp
# → Cantaloupeの管理画面が返る
7. ファイアウォールの閉鎖
Tunnelが正常に動作していることを確認したら、サーバのインバウンドポートを全て閉じる。cloudflaredはアウトバウンド接続のみなので、インバウンドは一切不要。
サービスごとの公開方針
Tunnel経由で全てのサービスを外部公開する必要はない。サービスの性質に応じて公開範囲を使い分ける。
公開してよいサービス
IIIF画像配信(Cantaloupe)のように、不特定多数のクライアントからアクセスされるサービスは、Tunnel経由で外部公開し、CDNキャッシュを活用する。
外部公開すべきでないサービス
Elasticsearchのような内部サービスは、一般的に外部公開しない。認証なしで公開するとデータの漏洩や改ざんのリスクがある。
対応方法はいくつかある。
方法1: Ingressから除外する
Elasticsearchをtunnel ingressに含めず、必要な時だけSSHポートフォワーディングで接続する。
# Zero Trust SSH経由でポートフォワード
ssh -L 9200:elasticsearch:9200 my-server-cf
ローカルの localhost:9200 でElasticsearchにアクセスでき、Next.js等の開発環境からは以下の設定で接続可能:
ELASTICSEARCH_URL=http://localhost:9200
方法2: Cloudflare Accessで保護する
Tunnel経由で公開するが、Cloudflare Accessの認証を必須にする。SSHと同様に、認証済みのユーザーのみアクセスを許可する。APIからのアクセスにはService Tokenを発行する。
方法3: 本番アプリからはTunnel内部で接続する
本番環境のアプリ(Cloudflare Workers等)からElasticsearchに接続する場合は、Tunnel内部のネットワーク(Service Binding等)を利用し、外部公開せずに接続する。
推奨構成
Tunnel経由で外部公開
└── Cantaloupe(IIIF画像配信)→ CDNキャッシュ有効
非公開(Ingressに含めない)
└── Elasticsearch(内部サービス)
開発時のアクセス
└── Zero Trust SSH ポートフォワーディング → localhost:9200
Elasticsearchの外部公開を停止する
Ingressルールからエントリを削除し、DNSレコードも削除する。
# Ingressから除外(ESのエントリを含めない)
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel/<TUNNEL_ID>/configurations" \
-H "X-Auth-Email: <EMAIL>" \
-H "X-Auth-Key: <API_KEY>" \
-H "Content-Type: application/json" \
--data '{
"config": {
"ingress": [
{"hostname": "iiif-cf.example.jp", "service": "http://cantaloupe:8182"},
{"service": "http_status:404"}
]
}
}'
# DNSレコードも削除
# Cloudflare API or ダッシュボードから該当CNAMEを削除
ローカル開発でElasticsearchに接続する
Zero Trust SSH経由のポートフォワーディングを使う。
# SSHポートフォワードを開始(ESコンテナのIPを指定)
ssh -L 9200:<ESコンテナIP>:9200 my-server-cf
ESコンテナのIPは以下で確認できる:
ssh my-server-cf "docker inspect elasticsearch --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'"
ポートフォワード中は localhost:9200 でESにアクセスできる。
Next.jsの.env.local:
ELASTICSEARCH_URL=http://localhost:9200
APIルート(app/api/search/route.ts)の例:
const ES_URL = process.env.ELASTICSEARCH_URL || "http://localhost:9200";
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q") || "";
const category = request.nextUrl.searchParams.get("category") || "";
const must = [];
if (q) {
must.push({ multi_match: { query: q, fields: ["title", "description"] } });
}
if (category) {
must.push({ term: { category } });
}
const res = await fetch(`${ES_URL}/documents/_search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: must.length > 0 ? { bool: { must } } : { match_all: {} },
aggs: { categories: { terms: { field: "category" } } },
}),
});
const data = await res.json();
return NextResponse.json({
hits: data.hits.hits.map((h) => h._source),
facets: data.aggregations.categories.buckets,
total: data.hits.total.value,
});
}
この構成により、Elasticsearchはインターネットに一切公開せず、開発時はSSHポートフォワード経由でのみアクセスする。
SSL証明書の注意点
Cloudflareの無料プランでは*.example.jp(1階層)のワイルドカード証明書が自動発行される。しかしes.cf.example.jpのような2階層のサブドメインはカバーされない。
| サブドメイン | 証明書 |
|---|---|
es-cf.example.jp(1階層) | ✅ カバーされる |
es.cf.example.jp(2階層) | ❌ カバーされない |
2階層を使いたい場合はAdvanced Certificate Manager($10/月)が必要。
従来構成(Traefik)との比較
アーキテクチャの違い
【Traefik構成】
インターネット
→ サーバ (80/443ポート開放)
└── Traefik(リバースプロキシ)
├── Cantaloupe
├── Elasticsearch
└── ModSecurity(WAFが必要な場合)
【Cloudflare Tunnel構成】
インターネット
→ Cloudflare(WAF・DDoS防御・SSL)
→ Tunnel(暗号化)
→ サーバ(ポート開放なし)
└── cloudflared
├── Cantaloupe
└── Elasticsearch
機能比較
| 項目 | Traefik + Let’s Encrypt | Cloudflare Tunnel |
|---|---|---|
| ポート開放 | 80, 443が必要 | 不要 |
| SSL管理 | Let’s Encrypt自動更新 | Cloudflareが完全管理 |
| WAF | なし(別途構築が必要) | あり(無料) |
| DDoS防御 | なし | あり(無料) |
| コスト | サーバ負担のみ | 無料 |
| 設定量 | Traefik設定 + ラベル | Docker Compose + API |
TraefikでWAFを実現する場合
Traefik単体にはWAF機能がない。WAFが必要な場合、ModSecurityコンテナを別途起動してTraefikのミドルウェアとして組み込む必要がある。
# Traefik + ModSecurity構成(参考)
services:
traefik:
image: traefik:latest
ports:
- "80:80"
- "443:443"
modsecurity:
image: owasp/modsecurity-crs:apache
environment:
- PARANOIA=1
cantaloupe:
labels:
- "traefik.http.routers.cantaloupe.middlewares=waf@docker"
この構成には以下の課題がある:
- ModSecurityの設定・チューニングが複雑
- 誤検知(false positive)への対応が必要
- CRS(Core Rule Set)のアップデート管理
- パフォーマンスへの影響
Cloudflare Tunnelでは、これらが全てCloudflare側で管理されるため、運用負荷が大幅に軽減される。
移行の容易さ
TraefikからCloudflare Tunnelへの移行は比較的容易。
- Traefikのラベル設定を削除
- docker-compose.ymlにcloudflaredコンテナを追加
- ポートの外部公開を削除
- Cloudflare側でIngressルールを設定
既存のサービスコンテナ(Elasticsearch、Cantaloupe等)はそのまま使える。
CDNキャッシュの設定
Cloudflare Tunnelで公開したサービスは、デフォルトではCDNキャッシュが効かない(cf-cache-status: DYNAMIC)。IIIFの画像タイルのように同じレスポンスが繰り返しリクエストされる場合、CDNキャッシュを有効にすることでオリジンサーバの負荷を大幅に軽減できる。
なぜデフォルトでキャッシュされないのか
Cloudflareは.jpg、.png等の静的ファイル拡張子を自動キャッシュするが、Tunnel経由のレスポンスではオリジンのCache-Controlヘッダや動的コンテンツの判定によりDYNAMIC(キャッシュ対象外)と判定されることがある。Cache Rulesで明示的にキャッシュを有効にすることで確実にCDNキャッシュが効くようになる。
Cache Rulesの設定
Cloudflare APIでホスト名単位のキャッシュルールを設定する:
curl -X POST "https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/rulesets" \
-H "X-Auth-Email: <EMAIL>" \
-H "X-Auth-Key: <API_KEY>" \
-H "Content-Type: application/json" \
--data '{
"name": "IIIF Cache Rules",
"kind": "zone",
"phase": "http_request_cache_settings",
"rules": [
{
"expression": "(http.host eq \"iiif-cf.example.jp\")",
"description": "Cache IIIF responses",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"edge_ttl": {
"mode": "override_origin",
"default": 86400
},
"browser_ttl": {
"mode": "override_origin",
"default": 86400
}
}
}
]
}'
設定後のレスポンスヘッダ
cf-cache-status: HIT ← CDNキャッシュから配信
age: 24 ← キャッシュされてからの秒数
cf-ray: xxxxx-NRT ← 成田のエッジサーバから配信
| ヘッダ値 | 意味 |
|---|---|
MISS | キャッシュなし。オリジンから取得してキャッシュに保存 |
HIT | キャッシュから配信。オリジンにアクセスしない |
DYNAMIC | キャッシュルール対象外 |
コスト
CDNキャッシュは無料プランで容量・帯域ともに無制限。IIIFの画像タイル配信に制限はない。
無料枠でどこまで使えるか
今回の構成で使用したCloudflareの機能は、全て無料プランの範囲内で利用できる。
| 機能 | 用途 | 無料枠 |
|---|---|---|
| DNS | ドメインのネームサーバ | 無制限 |
| Tunnel | サーバへの安全な接続 | 50本まで |
| CDN | 画像タイルのキャッシュ | 容量・帯域ともに無制限 |
| WAF | 攻撃防御 | 基本ルール含む |
| SSL | 証明書自動発行・更新 | 無制限 |
| Zero Trust Access | SSH認証等 | 50ユーザーまで |
研究用途で無料枠を超えることはまずない。費用が発生するのはサーバ(VPSやmdx等)の運用コストのみ。
なぜこれだけの機能が無料なのか
Cloudflareのビジネスモデルは、無料プランで大量のユーザーを獲得し、大企業向けのEnterprise/Proプランで収益化する構造になっている。個人・研究者・小規模プロジェクトは無料プランの恩恵を受ける側にいる。
今回の構成で得られたもの
従来構成と比較して、何が変わったかを整理する。
【従来(Traefik + VPS直接公開)】
- ポート開放が必要 → 攻撃リスク
- SSL証明書の管理が必要
- WAFなし → フレームワークの脆弱性が直撃
- CDNなし → オリジンに負荷集中
- サーバ管理者の運用負荷が大きい
【Cloudflare Tunnel + mdx】
- インバウンドポート全閉鎖 → 攻撃経路なし
- SSL/WAF/CDN/DDoS防御が全て自動
- SSHもZero Trustで保護
- 内部サービス(ES等)は外部非公開のまま開発可能
- Cloudflare側の費用ゼロ
実践例: Next.js + Elasticsearch + IIIF のデモアプリ
Cloudflare Tunnelの構成を活用して、検索・画像閲覧を備えたWebアプリを構築する実践例。
全体構成
ブラウザ
│
├── app-cf.example.jp → Next.js(検索UI)
│ Tunnel経由で公開
│
└── iiif-cf.example.jp → Cantaloupe(IIIF画像配信)
Tunnel経由で公開 + CDNキャッシュ
サーバ(Docker network内部)
├── cloudflared(Tunnelエージェント)
├── app(Next.js)
│ → elasticsearch:9200 に直接接続(Docker内部、<1ms)
├── elasticsearch(全文検索)← 外部非公開
└── cantaloupe(IIIF画像配信)
→ S3互換ストレージから画像取得
ポイント:
- Next.jsとElasticsearchは同じDockerネットワークに配置し、ES名前解決(
elasticsearch:9200)で直接接続。外部公開不要 - Cantaloupeの画像配信はCDNキャッシュを活用し、同じタイルの再リクエストはCloudflareのエッジから応答
- ブラウザからはTunnel経由のみでアクセス。サーバのインバウンドポートは全て閉鎖
docker-compose.yml
services:
cloudflared:
image: cloudflare/cloudflared:latest
restart: always
command: tunnel --protocol http2 run
environment:
TUNNEL_TOKEN: <トークン>
extra_hosts:
- host.docker.internal:host-gateway
networks:
- tunnel-network
elasticsearch:
image: elasticsearch:8.17.0
restart: always
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
volumes:
- es-data:/usr/share/elasticsearch/data
networks:
- tunnel-network
cantaloupe:
image: islandora/cantaloupe:6.3.12
restart: always
environment:
CANTALOUPE_SOURCE_STATIC: S3Source
CANTALOUPE_S3SOURCE_ENDPOINT: https://s3.example.jp
CANTALOUPE_S3SOURCE_REGION: us-east-1
AWS_ACCESS_KEY_ID: <アクセスキー>
AWS_SECRET_ACCESS_KEY: <シークレットキー>
CANTALOUPE_S3SOURCE_BASICLOOKUPSTRATEGY_BUCKET_NAME: my-bucket
CANTALOUPE_S3SOURCE_LOOKUP_STRATEGY: BasicLookupStrategy
CANTALOUPE_CACHE_SERVER_DERIVATIVE_ENABLED: "true"
CANTALOUPE_CACHE_SERVER_DERIVATIVE: FilesystemCache
volumes:
- cantaloupe_cache:/data
networks:
- tunnel-network
app:
build: ./es-search
restart: always
environment:
ELASTICSEARCH_URL: http://elasticsearch:9200
networks:
- tunnel-network
volumes:
es-data:
cantaloupe_cache:
networks:
tunnel-network:
ESにはポート公開(ports)を設定していない。app コンテナからDocker内部ネットワーク経由でのみアクセスする。
Tunnel Ingressの設定
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel/<TUNNEL_ID>/configurations" \
-H "Content-Type: application/json" \
--data '{
"config": {
"ingress": [
{"hostname": "app-cf.example.jp", "service": "http://app:3000"},
{"hostname": "iiif-cf.example.jp", "service": "http://cantaloupe:8182"},
{"service": "http_status:404"}
]
}
}'
Elasticsearchはingressに含めない。
Next.jsからElasticsearchへの接続
APIルート(app/api/search/route.ts)でESにアクセスし、検索結果にIIIF画像のURLを付与する:
const ES_URL = process.env.ELASTICSEARCH_URL; // http://elasticsearch:9200
const IIIF_BASE = "https://iiif-cf.example.jp";
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q") || "";
const category = request.nextUrl.searchParams.get("category") || "";
// Elasticsearchに検索クエリを発行
const res = await fetch(`${ES_URL}/documents/_search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: ...,
aggs: { categories: { terms: { field: "category" } } },
}),
});
const data = await res.json();
// IIIF画像URLを付与して返却
return NextResponse.json({
hits: data.hits.hits.map((h) => {
const source = h._source;
if (source.iiif_id) {
source.iiif_info = `${IIIF_BASE}/iiif/2/${encodeURIComponent(source.iiif_id)}/info.json`;
source.iiif_thumbnail = `${IIIF_BASE}/iiif/2/${encodeURIComponent(source.iiif_id)}/full/200,/0/default.jpg`;
}
return source;
}),
facets: data.aggregations.categories.buckets,
total: data.hits.total.value,
});
}
ELASTICSEARCH_URL はDocker内部の名前解決(http://elasticsearch:9200)を使うため、外部に公開する必要がない。
OpenSeadragonによるIIIF画像ビューア
検索結果のIIIF画像をクリックすると、OpenSeadragon(OSD)で高解像度ビューアが開く。OSDはクライアントサイドで動作し、iiif-cf.example.jp からタイルを取得する。タイルはCloudflareのCDNにキャッシュされるため、同じ領域の再表示は高速。
// OSDはクライアントサイドでのみ動作するため、dynamic importを使う
useEffect(() => {
import("openseadragon").then((OSD) => {
OSD.default({
element: viewerRef.current,
tileSources: [infoUrl], // IIIF info.json URL
});
});
}, [infoUrl]);
S3互換ストレージの注意点
CantaloupeがS3互換ストレージ(AWS以外)に接続する場合、AWS_REGION 環境変数の設定が必要。未設定だとAWS SDK v2がリージョン解決を繰り返しリトライし、タイル取得がタイムアウトする。実際のリージョンは問わないので us-east-1 等のダミー値を設定する。
まとめ
Cloudflare Tunnelを使うことで、サーバのインバウンドポートを完全に閉じた状態でサービスを安全に公開できる。従来必要だったリバースプロキシ(Traefik等)やSSL証明書の管理も不要になり、WAF・DDoS防御・CDNキャッシュも追加コストなしで利用できる。
サーバ上のDockerで動かすアプリ(Next.js等)とデータベース(Elasticsearch等)を同じネットワークに配置すれば、内部サービスを外部に公開することなく高速に連携でき、開発時はSSHポートフォワーディングでローカルから接続できる。