AWS 上で IP 制限+Basic 認証をかけて静的サイトをホストするアプローチ方法の比較
この記事は、Finatext Advent Calendar 2025 の 19 日目の記事です。
はじめに
証券事業のエンジニアの松﨑です。
社内ドキュメント(サービス仕様書など)を安全かつ手軽に、かつ可読性が高い状態で提供先へ公開したい。となったことはありませんか?
現在、自分のプロジェクトでは、MkDocs を使用してサービス仕様書を Markdown で記述し、静的サイト化し提供することに取り組んでいます。
AI が実用的になったこともあり、Markdown で生成したドキュメントを HTML 化することで、作成コストを下げつつメンテしやすく可読性も高められると思います。
ここで、ビルドの成果物を、運用や構築コストなどを考慮しバランスよく、いい感じにデプロイする必要がありました。ある程度機密な情報でもあるので、アクセス制限をかけたほうが良いという判断をし、機能面として具体的に以下の 2 点の要件を満たす必要がありました。
- IP アドレス制限
- Basic 認証
これを実現するために調査・比較検討を行い、最終的に採用した構成とその理由、そして Terraform や GitHub Actions を用いた実装詳細についてまとめます。
アプローチ 1:AWS Amplify Hosting
とりあえずドキュメントをサクッとホスティングするなら、AWS Amplify Hosting が最も手軽で良さそうだと考え、ざっと構築しました。
Basic 認証については、Amplify Hosting の標準機能として提供されており、簡単に設定できます。また、2025 年 3 月にリリースされたばかりの新機能として、Amplify Hosting と AWS WAF の統合が提供されており、WAF 側で IP 制限をかけることも可能でした。
実際、AWS コンソールベースですぐ出来たので感動しました。
構築手順
AWS コンソールをポチポチするだけで簡単に構築可能です。




ここまででアプリの作成が完了。ここに WAF を噛ませます。

こうして以下のような構成で構築が完了しました。
Amplify 構成図
特徴:
- Git 連携で自動ビルド・デプロイ
- Basic 認証が標準機能として提供
- 設定がシンプルで管理が楽
- WAF 統合の固定費が高い($15/月)
Amplify Hosting で WAF 統合機能を利用する場合、以下のコストが発生します
AWS Amplify WAF の料金
- Amplify アプリケーションあたり月額 15 USD
- AWS WAF の通常料金(Web ACL $5.00/月 + ルール利用料 + リクエスト料金)
今回のドキュメントサイトは、社内メンバーや一部の関係者のみが閲覧するもので、アクセス頻度は高くありません。とりあえず社内公開するという用途に対して、WAF 統合だけで月額固定費として 15 ドルかかるのは少々高すぎると感じました。1 ドル 150 円として年間 2.7 万円ですからね。
コスト
Amplify(で出来るリソース)はほぼ無料枠か誤差レベルで、WAF に 15 ドル+5 ドル+α ドルちょっとかかるので、トータル月額 20 ドル少々です。
参考
Amplify の WAF 統合以前に、Amplify に IP 制限をかける方法について書かれている記事もありました。ただしこれらは Amplify の CloudFront で WAF を使う方法なので、これを今やるのであれば Amplify を利用する意味が薄く、Amplify を使わず自分で全部構築してしまうのが良いのではないかと思います。
アプローチ 2:S3 + CloudFront + WAF
コストを抑えるために採用したのが、静的サイトホスティングの王道構成である S3 + CloudFront です。
今回は既に共通で使われていてちょうど良い WAF があったこともあり、そのまま流用する形で WAF を利用して IP 制限を実現しています。
S3 + CloudFront 構成図
構成要素:
- ホスティング: S3 バケット
- 配信: CloudFront
- IP 制限: AWS WAF Web ACL
- Basic 認証: CloudFront Functions
- 認証情報管理: CloudFront KeyValueStore
- セキュアアクセス: Origin Access Control (OAC)
コスト
WAF 料金 5 ドルと、誤差レベルのリソース代のみなので、月額およそ 5 ドル少々になります。
実装のポイント (Terraform)
今回の実装では、セキュリティと運用性を高めるために以下の工夫を行っています。
- Basic 認証情報の分離: コードにパスワードを書かず、CloudFront KeyValueStore (KVS) で管理。
- URL 補完ロジックの実装: MkDocs 等の静的サイトジェネレータ特有のパス構造に対応。
- インフラのモジュール化: 社内共通モジュールを利用して記述量を削減。
Basic 認証 × KeyValueStore (KVS)
Basic 認証の ID/Password を CloudFront Functions のコード内にハードコードするのは避けたいため、CloudFront KeyValueStore を利用しています。
# CloudFront KeyValueStore
resource "aws_cloudfront_key_value_store" "docs_basic_auth" {
name = "docs-basic-auth-store"
comment = "Basic authentication credentials for internal docs"
}
# CloudFront Function
resource "aws_cloudfront_function" "docs_basic_auth" {
name = "docs-basic-auth-function"
runtime = "cloudfront-js-2.0" # KVS を利用するため 2.0 を指定
comment = "Global basic authentication using KVS"
publish = true
code = <<-EOT
import cf from 'cloudfront';
const kvsHandle = cf.kvs();
// 認証失敗時のレスポンス生成関数
function unauthorizedResponse() {
return {
statusCode: 401,
statusDescription: 'Unauthorized',
headers: {
'www-authenticate': { value: 'Basic realm="Restricted Area"' }
}
};
}
async function handler(event) {
const request = event.request;
const headers = request.headers;
let expectedAuthString = null;
try {
// KVSから共通の認証情報(globalキー)を取得
const kvsValue = await kvsHandle.get('global');
if (kvsValue) {
// (省略: 値の取り出し処理)
// ...
// user:pass をBase64エンコード
const base64Credentials = btoa(credentials);
expectedAuthString = 'Basic ' + base64Credentials;
}
} catch (err) {
return unauthorizedResponse();
}
if (!expectedAuthString) {
return unauthorizedResponse();
}
// Authorizationヘッダーの検証
let authorizationHeader = headers.authorization ? headers.authorization.value : null;
if (!authorizationHeader || authorizationHeader !== expectedAuthString) {
return unauthorizedResponse();
}
// --- URL補完ロジック (後述) ---
if (request.uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!request.uri.includes(".")) {
request.uri += "/index.html";
}
return request;
}
EOT
key_value_store_associations = [
aws_cloudfront_key_value_store.docs_basic_auth.arn
]
}
ここで、KVS へのデータ登録(キー: global, 値: user:password)は、Terraform の状態管理から外すため、別途 CLI 等で行います。
補足:URL 補完ロジックについて
MkDocs などの静的サイトジェネレータは、http://example.com/getting-started/ のような「ファイル拡張子を持たない URL(ディレクトリパス)」でリンクを生成することが一般的です。
しかし、S3 で静的ウェブサイトホスティング機能を無効化し、CloudFront 経由で OAC (Origin Access Control) を使ってアクセスする場合、S3 は /getting-started/ というリクエストに対して、そのディレクトリ直下の index.html を自動的には返してくれません。S3 API は RESTful なオブジェクトストレージであり、オブジェクト単位でのアクセスになるためです。
そのため、S3 とリクエストの振る舞いの違いを CloudFront Functions で吸収している形になります。
2. インフラ定義(Module 利用)
S3 や CloudFront の構成は、社内標準の Terraform モジュールとして切り出しています。これにより、プロジェクト固有の設定(ドメインや WAF の紐付け)のみを記述するだけで済みます。
module "internal_docs" {
# 社内共通の S3+CloudFront テンプレートモジュールを呼び出し
source = "../../modules/common/template/s3_cloudfront/"
s3_bucket_name = "my-internal-docs-bucket"
certificate_us_east_1 = aws_acm_certificate.us_east_1.arn
cloudfront_logging_prefix = data.aws_caller_identity.current.account_id
# 作成しておいた WAF と Basic 認証 Function を紐付け
cloudfront_acl = aws_wafv2_web_acl.main.arn
cloudfront_functions_default_cache_behavior_viewer_request_arn = aws_cloudfront_function.docs_basic_auth.arn
domain = "docs.${aws_route53_zone.main.name}"
zone_id = aws_route53_zone.main.zone_id
response_headers_policy_id = aws_cloudfront_response_headers_policy.base.id
env = "dev"
}
S3+CloudFront でフロントを構築するケースは非常に多いので、一般的な構成を module 化しておいて使い回せるようにしています。具体的には以下のリソースを作成しています。
- S3 モジュール
- S3 バケット
- S3 バケットのバージョニング
- パブリックアクセスのブロック
- CloudFront
- CloudFront から S3 へのアクセス設定
- Route 53 レコード
関連して Terraform の管理について Finatext アドベントカレンダー 5 日目にぐらにゅさんの記事で触れられているので、ご興味があればこちらも是非御覧ください。
デプロイフロー (GitHub Actions)
デプロイについても、GitHub Actions で自動化しています。
GitHub OIDC を利用して必要な権限だけ渡しています
name: Build and Deploy Service Specification to S3
on:
push:
branches: [master]
paths:
- 'mkdocs/**'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies & Build
run: |
pip install -r requirements.txt
mkdocs build --clean
# OIDCを使用してセキュアに認証
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ap-northeast-1
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
# S3へのデプロイ(Sync)
- name: Deploy to S3
run: |
aws s3 sync site/ s3://my-internal-docs-bucket/ \
--delete \
--cache-control "public, max-age=3600" \
--exclude "*.map"
# キャッシュ削除
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
アプローチ 2' CloudFront Functions で IP 制限も実装する
今回、実は既にやりたい制限に合う WAF が既にあったこともあり、手間を考えてアプローチ 2 で進めましたが。CloudFront Functions で viewer の IP を取得することで IP 制限を実現する方法もあります。この方針だと WAF のコストすらかかりません。CloudFront Functions や KVS の利用料金はとても安いので、数万程度のオーダーのリクエストであればほぼ無料で運用可能です。
実現方法は、KVS を利用して IP リストを管理し、CloudFront Functions で参照し、Key があれば許可という形にします。
CIDR での指定は少し作り込みが必要になったり、IPv6 だと Terraform しますが、特定の IP だけ許可、という要件であれば十分利用可能だと思います。
IP 制限の CloudFront Functions コード
import cf from 'cloudfront';
const kvsHandle = cf.kvs();
async function handler(event) {
const request = event.request;
const clientIp = event.viewer.ip;
try {
await kvsHandle.get(clientIp);
// get出来たらホワイトリストに登録されていると判断
return request;
} catch (err) {
// get出来ない場合は許可されていないと判断
return {
statusCode: 403,
statusDescription: 'Forbidden',
headers: {
"content-type": { "value": "text/html; charset=UTF-8" }
},
body: {
"encoding": "text",
"data": "<h1>403 Forbidden</h1><p>Access denied.</p>"
}
};
}
}
KVS はこのような感じで定義してあげましょう。
locals {
allowed_ips = {
"1.2.3.4" = "Office Network"
"192.168.1.1" = "Home Network"
}
}
resource "aws_cloudfront_key_value_store" "ip_allowlist" {
name = "ip-allowlist"
comment = "Allowed IPs"
}
resource "aws_cloudfrontkeyvaluestore_key" "ips" {
for_each = local.allowed_ips
key_value_store_arn = aws_cloudfront_key_value_store.ip_allowlist.arn
key = each.key
value = each.value
}
注意点
CloudFront Functions KVS は、Key-Value の更新のコストが高いです。この手法を応用したとして、大量の IP を動的に更新するようなユースケースには適していないので注意してください。
具体的には 1000 回の更新リクエストで 1 ドルかかります。
参考: CloudFront Functions 料金ページ
Terraform で Key を管理する場合は、必要に応じてこのリソースの活用も推奨されています。
コスト等の比較の上での結論
コスト比較:
| 項目 | Amplify + WAF | S3 + CloudFront + WAF | S3 + CloudFront + Functions (IP 制限) |
|---|---|---|---|
| 固定費 | $20/月 | $5/月 | $0/月 |
| 従量課金 | ビルド時間・転送量 | 転送量・リクエスト数 | 転送量・リクエスト数・Functions 実行 |
| 月間想定コスト | 約$20〜23 | 約$5〜8 | 約$1〜3 |
低トラフィックな前提ですが、Amplify の WAF 統合 15 ドル分 Amplify が高くなります。
構築の手間は S3 + CloudFront 形式の方がかかります。
しかし、今回はモジュール整備がなされていたことや慣れていたこともありサッと構築できたので、シンプルにコストが安い方に乗り換えました。
番外編:ゼロから作るなら Cloudflare Pages という選択肢もある
普段使っているのが AWS で、前述の通り Terraform Module などの環境も整っていたのもあり、AWS 上で作る事を考えてきました。
ただ、よくよく調べていくと Cloudflare Pages を使うことで同様の要件を非常に低コストで実現できそうだったので試してみました。
Cloudflare には Cloudflare Zero Trust という製品群があり、その中の Cloudflare Access を利用すると、50 ユーザーまで無料で非常に強力なアクセス制御を実現できます。
小規模に使うのであれば、Amplify Hosting の感覚で構築でき、よりアクセス制御の機能が充実している、というように思いました。
Cloudflare 構成図
- 50 ユーザーまで完全無料
- GUI で簡単設定
- IP 制限、Google Workspace 認証などポリシーが柔軟
- GitHub 連携で自動デプロイ
構築手順
- Add から、Pages を追加する(ここ最初見つからず手間取りました)
Git repository 連携で進めます。

repository 選択したら、MkDocs をフレームワークに設定したら、ビルドコマンドなどがよしなにフィルインされるので、Save and Deploy 押したら完了でした。

アクセス制御は、Access controls→Applications から、色々な設定が可能です。
例えば、許可メールアドレスを登録し、One-time PIN を利用する、など。
多機能すぎて逆に難しいような気もしますが、とりあえず軽く触るくらいであれば GUI だけで分かりそうでした。細かくは公式ドキュメントを読んだり、
検証の記事を見てみるのが良いかもしれません。
Basic 認証よりも強力な認証を簡単に導入でき、しかも完全に無料で使えるのは非常に魅力的に思います。
補足:Cloudflare を使うにあたり注意点
Cloudflare だと Terraform 管理が少し手間なのと、AI の学習量が少し足りてないという話をチームメンバーに聞きました。
前者については、Terraform の State 管理において、Cloudflare ではロックに対応していないため、別途 S3 や Terraform Cloud などを利用する必要があります。そのため、大規模に使っていくとなると痒い所に手が届かない、みたいなケースもあるかもしれません。
後者は今回試した限りだとそんなに精度悪いとは感じなかったです。
番外編:その他の静的サイトホスティング選択肢
調査していると、静的サイトホスティングには、他にも様々な選択肢がありました。
詳細な所は省きますが、IP 制限+Basic 認証を(無料枠では)満たせはしないものの、とりあえずデプロイだけで良ければ
- GitHub Pages
- Vercel
- Netlify
あたりが候補に上がってくると思います。また、既に AWS、Google Cloud、Azure を利用しているのならそれを利用するのが、組織の管理上は良いように思いました。
まとめ
今回は、社内ドキュメントを安全に公開するために、IP 制限と Basic 認証を実装した静的サイトホスティングの構成を比較検討しました。
最終的には、普段から AWS を利用していて運用しているのであれば、CloudFront Functions と KVS を利用して IP 制限、Basic 認証ともに実装してしまう構成が最もコストパフォーマンスが良いのではないかなと思いました。
実際には、S3 + CloudFront + WAF で構築しています。IP 制限の範囲が既存の WAF と同じだったので流用した形で、WAF の追加コストも実質かからなかったためです。IP 制限を WAF と変えていくとなったら、CloudFront Functions で実装する形に変更しようと思います。
おまけの Cloudflare Pages の選択肢も含めて再度まとめると以下の通りです。
| 項目 | Amplify + WAF | S3 + CloudFront + WAF | S3 + CloudFront + Functions (IP 制限) | Cloudflare Pages + Access |
|---|---|---|---|---|
| 固定費 | $20/月 | $5/月 | $0/月 | $0/月 |
| 従量課金 | ビルド時間・転送量 | 転送量・リクエスト数 | 転送量・リクエスト数・Functions 実行 | 転送量(無料枠内) |
| 月間想定コスト | 約$20〜23 | 約$5〜8 | 約$1〜3 | 約$0 |
結果的に、最初に検討していた Amplify での構築よりも月額 15-20 ドル程度コストを抑えることが出来ました。
また、調査の過程で Cloudflare Pages + Cloudflare Access という選択肢も知ることができました。50 ユーザーまで完全無料で、GUI での設定も簡単なため、PoC 的にやる場合や個人開発においてはかなり有力な候補となりそうです。
Discussion