ベストプラクティス¶
Django API + SPA フロントエンドを同一ドメインで配信し、メディアファイルを署名付き URL で保護する推奨構成です。
アーキテクチャ¶
Browser → CloudFront (example.com)
├─ /* → Origin(path=/web/app) → S3: web/app/...
│ CF Function: SPA fallback のみ
├─ /api/* → API Gateway → Lambda (Django)
└─ /static/* → Origin(path=/web) → S3: web/static/... (deploystatic で管理)
Browser → CloudFront (media.example.com)
└─ /* → Origin(path=/usercontent) → S3: usercontent/... (署名付きURL)
- SPA + API を同一ドメインに統合し、Cookie 認証(session + CSRF)をそのまま利用
- メディアファイルは別ドメインで署名付き URL により保護
- Django staticfiles は S3 に直接配置し、Lambda コンテナには含めない
推奨 pocket.toml¶
[general]
region = "ap-northeast-1"
stages = ["dev", "prod"]
[general.django_fallback.storages]
default = { store = "filesystem" }
staticfiles = { store = "filesystem", static = true }
[s3]
[neon]
project_name = "dev-myproject"
[awscontainer]
dockerfile_path = "pocket.Dockerfile"
[awscontainer.handlers.wsgi]
command = "pocket.django.lambda_handlers.wsgi_handler"
apigateway = {}
[awscontainer.handlers.management]
command = "pocket.django.lambda_handlers.management_command_handler"
timeout = 600
[awscontainer.secrets.managed]
SECRET_KEY = { type = "password", options = { length = 50 } }
DJANGO_SUPERUSER_PASSWORD = { type = "password", options = { length = 16 } }
DATABASE_URL = { type = "neon_database_url" }
CF_MEDIA_KEY = { type = "cloudfront_signing_key", options = { pem_base64_environ_suffix = "_PEM_BASE64", pub_base64_environ_suffix = "_PUB_BASE64", id_environ_suffix = "_ID" } }
[awscontainer.django.storages]
default = { store = "s3", distribution = "usercontent" }
staticfiles = { store = "s3", distribution = "web", route = "static", static = true, manifest = true }
# SPA + API 同一ドメイン
[cloudfront.web]
routes = [
{ is_default = true, is_spa = true, build = "just frontend-build", build_dir = "frontend/dist", origin_path = "/web/app" },
{ path_pattern = "/static/*", ref = "static", versioning = "content_hash", origin_path = "/web" },
{ path_pattern = "/api/*", type = "lambda", handler = "wsgi" },
]
# メディア(署名付きURL)
[cloudfront.usercontent]
signing_key = "CF_MEDIA_KEY"
routes = [
{ is_default = true, signed = true, origin_path = "/usercontent" },
]
# --- ステージ固有設定 ---
[dev.awscontainer.handlers.wsgi]
apigateway = {}
[prod.neon]
project_name = "prod-myproject"
[prod.awscontainer.handlers.wsgi]
apigateway = {}
[prod.cloudfront.web]
domain = "example.com"
redirect_from = [{ domain = "www.example.com" }]
[prod.cloudfront.usercontent]
domain = "media.example.com"
設計の理由¶
- SPA + API 同一ドメイン(Cookie 認証)
- CloudFront が
/*→ S3、/api/*→ API Gateway とルーティングします。 同一ドメインなので Cookie がそのまま送信され、CORS 設定は不要です。 Django 側ではCSRF_COOKIE_DOMAINとCSRF_TRUSTED_ORIGINSを設定してください。 各 route のorigin_pathで S3 Origin のOriginPathを設定し、 S3 上ではweb/app/配下に SPA、web/static/配下に staticfiles を分離配置します。 build/build_dirでフロントエンド自動デプロイpocket deployでインフラ更新に加え、SPA のビルド → S3 アップロード → CloudFront キャッシュ無効化が自動実行されます。 SPA の HTML はno-cache、アセットはmax-age=1年のキャッシュヘッダーが設定されます。 インフラのみ更新したい場合は--skip-frontendで抑制できます。- SPA の画像・フォントはビルドパイプラインを通す
- Vite / SvelteKit 等の
public/(static/) に置いたファイルはビルドを通らずファイル名にハッシュが付きません。 CloudFront やブラウザのキャッシュが残り、画像を差し替えても古い内容が表示される原因になります。 画像・フォントなどの静的アセットはsrc/配下に置き、importやimport.meta.glob経由で参照してください。 ビルド時に自動でハッシュ付きファイル名が生成され、versioningroute の長期キャッシュと安全に共存できます。 - staticfiles は
pocket django deploystaticで管理 - Django の静的ファイル(admin CSS 等)はローカルで
collectstatic→ S3 アップロードする仕組みです。 Lambda コンテナに静的ファイルを含めないため、イメージサイズを抑えられます。distribution = "web"とroute = "static"を指定することで、CloudFront 経由(example.com/static/...)で配信されます。 S3 上の location(web/static)は route のorigin_pathとpath_patternから自動計算されるため、locationの手動指定は不要です。 - Lambda 上の staticfiles Storage は S3 backend を使う
versioning = "deploy_hash"の場合のみ Lambda 上でStaticFilesStorageを使い、 それ以外ではCloudFrontS3StaticStorage等の S3 backend を使います。 理論上、manifest = falseかつsigned = falseなら S3 backend は URL 生成に不要 (StaticFilesStorage+STATIC_URLで同じ URL が出る)ですが、 S3 backend を外すとユーザーにSTATIC_URLの明示設定を要求することになるため、 deploy_hash 以外では S3 backend を利用しています。 S3 backend が Lambda 上で必須なのはmanifest = true(S3 上の manifest を読む)とsigned = true(署名付き URL を生成する)の 2 ケースです。- メディアを別 CloudFront + 署名付き URL で保護
- ユーザーがアップロードした画像・ファイル等は
media.example.com経由で配信します。signing_keyによる署名付き URL により、Django が生成した URL でのみアクセス可能です。 鍵ペアの生成・管理は magic-pocket が自動で行います。 - Neon のプロジェクトをステージで分離
- dev と prod で Neon プロジェクトを分けることで、開発環境の操作が本番に影響しません。
TiDB Serverless を使う場合
Neon の代わりに TiDB Serverless(MySQL 互換)を使う場合は、[neon] を [tidb] に、DATABASE_URL の type を tidb_database_url に変更します。
[tidb]
project = "1234567890123456789"
[awscontainer.secrets.managed]
SECRET_KEY = { type = "password", options = { length = 50 } }
DATABASE_URL = { type = "tidb_database_url" }
詳しいコールドスタート特性は「コールドスタート」を参照してください。
RDS Aurora を使う場合
Neon の代わりに RDS Aurora PostgreSQL Serverless v2 を使う場合は、[neon] と DATABASE_URL managed secret の代わりに [rds] と [vpc] を設定します。
SPA トークン認証¶
SPA にログイン必須機能を追加する場合、require_token を設定します。
CloudFront Function が Cookie 内の HMAC-SHA256 トークンを検証し、未認証ユーザーをログインページにリダイレクトします。
[awscontainer.secrets.managed]
SECRET_KEY = { type = "password", options = { length = 50 } }
DATABASE_URL = { type = "neon_database_url" }
SPA_TOKEN_SECRET = { type = "spa_token_secret" }
[cloudfront.web]
token_secret = "SPA_TOKEN_SECRET"
routes = [
{ is_default = true, is_spa = true, require_token = true, build = "just frontend-build", build_dir = "frontend/dist", origin_path = "/web/app" },
{ path_pattern = "/static/*", ref = "static", versioning = "content_hash", origin_path = "/web" },
{ path_pattern = "/api/*", type = "lambda", handler = "wsgi" },
]
仕組み
- CloudFront Function(viewer-request)が全リクエストを検証
- Cookie
pocket-spa-tokenに含まれるトークン({user_id}:{expiry}:{hmac})を HMAC-SHA256 で検証 - シークレットは CloudFront KeyValueStore (KVS) に格納(Function コードには埋め込まない)
- 未認証・期限切れの場合、
login_path(デフォルト/api/auth/login)に 302 リダイレクト /api/*は API Gateway にルーティングされるため、トークン検証の対象外
Django 側の実装
SpaTokenCookieMiddleware を 必ず MIDDLEWARE に追加してください
(AuthenticationMiddleware の後)。Django session (デフォルト 14 日) が
SPA token (デフォルト 7 日) より長生きするため、token 切れ後に session で
ログインページを素通り redirect されて token が再発行されない
無限 redirect ループに陥ります。middleware が「認証済み response には
必ず token が乗っている」を担保することでループを断ち切ります。
# settings.py
MIDDLEWARE = [
# ...
"django.contrib.auth.middleware.AuthenticationMiddleware",
"pocket.django.spa_auth.SpaTokenCookieMiddleware",
# ...
]
ログイン / ログアウトビュー側は薄く済みます (middleware が後始末するので
明示的に spa_login / spa_logout を呼ぶ必要は厳密にはありませんが、
明示しておくと意図が読み取りやすいです):
from django.http import HttpResponseRedirect
from pocket.django.spa_auth import spa_login, spa_logout
# ログインビュー(/api/auth/login)
def login_view(request):
# Django 認証でユーザーを検証...
response = HttpResponseRedirect(request.GET.get("next", "/"))
spa_login(response, str(request.user.id)) # middleware でも補填されるが明示推奨
return response
詳細は「実行環境 - SPA トークン認証」を参照してください。
デプロイ手順¶
# 初回デプロイ
pocket deploy --stage=dev
pocket django manage migrate --stage=dev
pocket django deploystatic --stage=dev
pocket django manage createsuperuser --username=admin --email=admin@example.com --noinput --stage=dev
# 通常のデプロイ(インフラ + フロントエンド)
pocket deploy --stage=dev
# フロントエンドのみ更新
pocket resource cloudfront upload --stage=dev
# インフラのみ更新(フロントエンドスキップ)
pocket deploy --stage=dev --skip-frontend