コンテンツにスキップ

ベストプラクティス

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_DOMAINCSRF_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/ 配下に置き、importimport.meta.glob 経由で参照してください。 ビルド時に自動でハッシュ付きファイル名が生成され、versioning route の長期キャッシュと安全に共存できます。
staticfiles は pocket django deploystatic で管理
Django の静的ファイル(admin CSS 等)はローカルで collectstatic → S3 アップロードする仕組みです。 Lambda コンテナに静的ファイルを含めないため、イメージサイズを抑えられます。 distribution = "web"route = "static" を指定することで、CloudFront 経由(example.com/static/...)で配信されます。 S3 上の location(web/static)は route の origin_pathpath_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] を設定します。

[vpc]
ref = "main"
zone_suffixes = ["a", "c"]

[rds]

[awscontainer]
dockerfile_path = "pocket.Dockerfile"

[awscontainer.secrets.managed]
SECRET_KEY = { type = "password", options = { length = 50 } }
# DATABASE_URL は不要。[rds] があれば自動提供

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" },
]

仕組み

  1. CloudFront Function(viewer-request)が全リクエストを検証
  2. Cookie pocket-spa-token に含まれるトークン({user_id}:{expiry}:{hmac})を HMAC-SHA256 で検証
  3. シークレットは CloudFront KeyValueStore (KVS) に格納(Function コードには埋め込まない)
  4. 未認証・期限切れの場合、login_path(デフォルト /api/auth/login)に 302 リダイレクト
  5. /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