はじめに
SST v3 を使って、Vue.js のアンケートアプリを AWS 上にデプロイするプロジェクトに取り組みました。
構成は S3 + CloudFront で静的サイトを配信し、API Gateway + Lambda + DynamoDB でバックエンド API を動かすサーバーレスアーキテクチャです。
この記事では SST v3 によるインフラ定義 にフォーカスして、実際のコードを交えながら解説します。
対象読者
- AWS のサービス(S3, CloudFront, Lambda など)は名前と役割をなんとなく知っている
- IaC や SST はこれから触る or 触り始めたばかり
この記事のゴール
- SST v3 の基本的な書き方がわかる
- SST で S3 + CloudFront の静的サイト配信を構築できる
- CloudFront に API Gateway を相乗りさせるパターンを理解できる
まずは用語解説
記事中に登場する単語や概念を先に整理しておきます。今回自分は初めて AWS サービスを使用して案件に取り組みましたが、初めて触れる単語ばかりかなり混乱しました。AWS サービス自体の詳しい説明は省略し、SST を理解するために必要な粒度で解説します。
IaC (Infrastructure as Code)
サーバーやデータベースなどのインフラ構成を、手動で AWS コンソールをポチポチするのではなく コードで定義・管理する アプローチです。「このテーブルのキーはこれ」「この Lambda はこの S3 にアクセスできる」といった設定をすべてコードに書きます。
代表的なツール: Terraform, AWS CDK, CloudFormation, Pulumi, SST
Pulumi
IaC ツールの一つで、TypeScript や Python などの汎用プログラミング言語 でインフラを定義できるのが特徴です。SST v3 は内部でこの Pulumi を使っています(v2 までは AWS CDK ベースでした)。
SST の主要コンポーネント
| 用語 | 説明 |
|---|---|
| StaticSite | SST が提供する高レベルコンポーネント。S3 バケット + CloudFront ディストリビューション + OAC をまとめて1つのリソースとして扱える。ビルドコマンドや環境変数の注入もここで設定する |
| link | SST のリソース間接続の仕組み。例えば Lambda に DynamoDB テーブルを link すると、Lambda 側で Resource.テーブル名.name のようにテーブル名を取得できる。IAM 権限も自動で付与される |
| sst.Secret | デプロイ時にシークレット(API キー、パスワード等)を安全に注入する仕組み。v3 では値は AWS アカウント内の S3 バケットに暗号化して保存される(v2 では SSM Parameter Store を使用していた) |
| ステージ | dev / stg / prod のような環境を分離する概念。同じコードから複数の独立した環境をデプロイできる |
| $transform | SST が生成するリソースのデフォルト設定を上書きする仕組み。例えば全 Lambda のランタイムを一括で変更できる |
| $dev |
sst dev(ローカル開発モード)で実行中かどうかを判定するフラグ |
| transform (リソース単位) | 個別のリソースに対して、SST が隠蔽している AWS リソースの設定を直接カスタマイズする仕組み。CloudFront のオリジン追加など、SST の API だけでは表現しきれない設定に使う |
AWS サービス(一言解説)
| サービス | この記事での役割 |
|---|---|
| S3 | ビルド後の HTML/CSS/JS を保存するストレージ |
| CloudFront | S3 のコンテンツを世界中のエッジサーバーから高速配信する CDN |
| OAC | CloudFront だけが S3 にアクセスできるようにするアクセス制御 |
| API Gateway | HTTP リクエストを受けて Lambda を呼び出す入り口 |
| Lambda | サーバーレスで動くバックエンド関数 |
| CloudFront Function | CloudFront のエッジで動く軽量な JavaScript 関数。URL 書き換えなどに使う |
SST v3 とは
SST (Serverless Stack) は、AWS 上に サーバーレスアプリケーション を構築するための IaC フレームワークです。
名前の通りサーバーレス構成(Lambda, S3, DynamoDB, API Gateway など)に特化しており、これらのサービスを少ないコードで連携させることに強みがあります。逆に、EC2 でサーバーを立てて運用するような構成には向いていないため、その場合は Terraform や AWS CDK を選択したほうがよいでしょう。
v3 からは内部エンジンが AWS CDK → Pulumi に変わり、TypeScript でインフラを定義します。個人的に感じた SST v3 のメリットは以下の通りです。
-
高レベルコンポーネントが便利:
StaticSite1つで S3 + CloudFront + OAC + ビルドをまとめて管理できる -
linkでリソース間の接続が簡単: Lambda から DynamoDB にアクセスする設定が数行で済み、IAM 権限も自動付与される - ステージ管理が組み込み: 同じコードで dev / stg / prod を分離できる
-
sst devでライブ開発: Lambda のコード変更が即座に反映されるライブモードがある
プロジェクト構成
実際のプロジェクトでは、以下のようにインフラ定義を分割しました。
├── sst.config.ts # エントリーポイント
├── infra/
│ ├── storage.ts # DynamoDB
│ ├── api.ts # API Gateway + Lambda
│ ├── auth.ts # Cognito (管理者認証)
│ └── web.ts # S3 + CloudFront (静的サイト)
├── web/ # Vue.js フロントエンド
└── handlers/ # Lambda ハンドラー
sst.config.ts — エントリーポイント
SST の設定はすべて sst.config.ts に集約されます。app() でプロジェクト全体の設定、run() でリソースの構築を行います。
export default $config({
app(input) {
// ステージに応じて AWS プロファイルを切り替え
const profile =
input.stage === "prod"
? "my-project-prod"
: input.stage === "stg"
? "my-project-stg"
: "my-project-dev";
return {
name: "my-project",
removal: input?.stage === "production" ? "retain" : "remove",
home: "aws",
providers: {
aws: { profile },
},
};
},
async run() {
// SST v3 のデフォルトは Node.js 20 だが、20 系は 2026/4/30 でサポート終了するため
// $transform で全 Lambda のランタイムを Node.js 22 に一括変更
$transform(sst.aws.Function, (args) => {
args.runtime ??= "nodejs22.x";
});
// infra/ のモジュールを順番に読み込んでリソースを構築
const storage = await import("./infra/storage");
const auth = await import("./infra/auth");
const api = await import("./infra/api");
const web = await import("./infra/web");
// ... Cognito User Pool Client の設定など
},
});
ポイントは以下の通りです。
-
app()はプロジェクト全体の設定: AWS プロファイル、リソース削除時の挙動(removal)、プロテクション設定など -
run()でリソースを構築:infra/配下のモジュールをimportして、リソース間の依存関係を解決しながら構築する -
$transformでデフォルト値を一括設定: SST v3 のデフォルトランタイムは Node.js 20 ですが、20 系は 2026/4/30 でサポートが終了します。$transformを使えば、各 Lambda に個別指定しなくても全関数のランタイムを一括で Node.js 22 に上げられます
infra/ のファイル分割
1ファイル = 1つの AWS サービスグループ として分割しています。
// infra/storage.ts — DynamoDB テーブル定義
export const table = new sst.aws.Dynamo("SurveyResponses", {
fields: {
userId: "string",
createdAt: "string",
},
primaryIndex: { hashKey: "userId", rangeKey: "createdAt" },
});
// infra/api.ts — API Gateway + Lambda
import { table } from "./storage";
export const api = new sst.aws.ApiGatewayV2("SurveyApi");
api.route("POST /survey", {
handler: "handlers/src/survey.handler",
link: [table], // Lambda から DynamoDB にアクセスできるようにする
});
link: [table] と書くだけで、Lambda 側に DynamoDB へのアクセス権限が付与され、Resource.SurveyResponses.name でテーブル名を取得できるようになります。IAM ポリシーの手書きが不要になるのは大きなメリットです。
StaticSite で Vue.js を S3 + CloudFront 配信する
SST の StaticSite コンポーネントを使うと、S3 バケットの作成、CloudFront ディストリビューションの設定、OAC の構成をまとめて行えます。
// infra/web.ts
export const site = new sst.aws.StaticSite("SurveyWeb", {
path: "web", // Vue.js プロジェクトのルート
build: {
command: "npm run build", // ビルドコマンド
output: "dist", // ビルド成果物のディレクトリ
},
environment: {
VITE_API_URL: api.url, // Vite の環境変数として API URL を注入
},
});
たったこれだけで以下が自動構築されます。
- S3 バケット(ビルド成果物のアップロード先)
- CloudFront ディストリビューション(CDN 配信)
- OAC(S3 への直接アクセスを禁止し、CloudFront 経由のみに制限)
- ビルドの実行と S3 へのデプロイ
environment に指定した値は、ビルド時に Vite の環境変数(import.meta.env.VITE_API_URL)として注入されます。
CloudFront 経由で API Gateway を統合する
今回のプロジェクトでは、フロントエンドと API を 同じ CloudFront ディストリビューション から配信しています。
https://xxxxx.cloudfront.net/ → S3 (Vue.js)
https://xxxxx.cloudfront.net/api/* → API Gateway (Lambda)
なぜ CloudFront に API を相乗りさせるのか
フロントと API を別ドメインにすると、ブラウザの CORS (Cross-Origin Resource Sharing) 制約を考慮する必要があります。同じ CloudFront ドメインから配信すれば、同一オリジンになるため CORS の設定が不要になります。
実装
SST の StaticSite が作る CloudFront に、API Gateway をオリジンとして追加します。ただし SST の API では CloudFront のオリジンを直接追加できないため、transform.cdn を使って AWS リソースを直接カスタマイズします。
// CloudFront Function: /api/* のプレフィックスを除去して API Gateway に転送
const stripApiPrefix = new aws.cloudfront.Function("StripApiPrefix", {
runtime: "cloudfront-js-2.0",
code: `
function handler(event) {
var request = event.request;
request.uri = request.uri.replace(/^\\/api/, '');
if (request.uri === '') {
request.uri = '/';
}
return request;
}
`,
});
CloudFront に /api/* のリクエストが来ると、この CloudFront Function がプレフィックスを除去してから API Gateway に転送します。例えば /api/survey → /survey に変換されます。
// API Gateway をオリジンとして定義
const apiOrigin = {
domainName: api.url.apply((url: string) => new URL(url).hostname),
originId: "apiGateway",
customOriginConfig: {
httpPort: 80,
httpsPort: 443,
originProtocolPolicy: "https-only",
originSslProtocols: ["TLSv1.2"],
},
customHeaders: [
{
name: "x-api-secret",
value: apiGatewaySecret.value, // API Gateway の認証用シークレット
},
],
};
// /api/* へのリクエストを API Gateway に振り分けるキャッシュビヘイビア
const apiCacheBehavior = {
pathPattern: "/api/*",
targetOriginId: "apiGateway",
allowedMethods: ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"],
cachedMethods: ["GET", "HEAD"],
viewerProtocolPolicy: "redirect-to-https",
cachePolicyId: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad", // CachingDisabled
originRequestPolicyId: "b689b0a8-53d0-40ab-baf2-68738e2966ac", // AllViewerExceptHostHeader
functionAssociations: [
{
eventType: "viewer-request",
functionArn: stripApiPrefix.arn,
},
],
};
最後に、transform.cdn で SST が作った CloudFront にこれらを追加します。
export const site = new sst.aws.StaticSite("SurveyWeb", {
// ... 省略
transform: {
cdn: (args) => {
const a = args as any;
// API Gateway をオリジンに追加
a.origins = $output(a.origins).apply((existing: unknown[]) => [
...existing,
apiOrigin,
]);
// /api/* のキャッシュビヘイビアを追加
a.orderedCacheBehaviors = $output(a.orderedCacheBehaviors ?? []).apply(
(existing: unknown[]) => [...existing, apiCacheBehavior]
);
},
},
});
transform.cdn は SST が隠蔽している CloudFront ディストリビューションの設定に直接アクセスできる仕組みです。SST の高レベル API だけでは表現できない設定を行う場合に使います。
シークレット管理
API キーやパスワードなどの秘匿情報は sst.Secret で管理します。
// infra/api.ts
export const apiGatewaySecret = new sst.Secret("ApiGatewaySecret");
値のセットは CLI から行います。
npx sst secret set ApiGatewaySecret "your-secret-value"
npx sst secret set ApiGatewaySecret "different-value" --stage prod # ステージ指定
今回のプロジェクトでは、ステージごとのシークレット設定を Makefile にまとめて運用しました。
set-secrets:
npx sst secret set AdminEmail "admin@example.com" && \
npx sst secret set ApiGatewaySecret "xxx..."
set-secrets-stg:
npx sst secret set AdminEmail "admin@example.com" --stage stg && \
npx sst secret set ApiGatewaySecret "xxx..." --stage stg && \
npx sst secret set BasicAuthUsername "stg" --stage stg && \
npx sst secret set BasicAuthPassword "xxx" --stage stg
make set-secrets-stg のようにワンコマンドで設定できるので、環境構築時に便利です。
ステージ管理 (dev / stg / prod)
SST のステージ機能を使って、同じコードから複数の環境をデプロイしています。
AWS プロファイルの自動切り替え
app() でステージに応じた AWS プロファイルを返すようにしています。
app(input) {
const profile =
input.stage === "prod"
? "my-project-prod"
: input.stage === "stg"
? "my-project-stg"
: "my-project-dev";
return {
// ...
providers: { aws: { profile } },
};
}
デプロイ時に --stage を指定するだけで、対応する AWS アカウントにデプロイされます。
npx sst deploy # dev (デフォルト)
npx sst deploy --stage stg # ステージング
npx sst deploy --stage prod # 本番
$dev でローカル開発時の分岐
sst dev で開発中は、CloudFront の URL がまだ存在しません。$dev フラグを使って、ローカル開発時だけ別の挙動にできます。
const LOCAL_DASHBOARD_URL = "http://localhost:5173/dashboard";
// sst dev 時は localhost のみ、デプロイ時は CloudFront URL も追加
const callbackUrls = $dev
? [LOCAL_DASHBOARD_URL]
: [$interpolate`${web.site.url}/dashboard`, LOCAL_DASHBOARD_URL];
stg / prod 限定の Basic 認証
ステージング・本番環境だけ Basic 認証をかける設定も、ステージの値で分岐しています。
const BASIC_AUTH_STAGES = ["stg", "prod"];
const needsBasicAuth = BASIC_AUTH_STAGES.includes($app.stage);
const basicAuthUsername = needsBasicAuth
? new sst.Secret("BasicAuthUsername")
: undefined;
Basic 認証自体は CloudFront の edge オプションで、ビューワーリクエスト時に認証チェックを注入しています。
export const site = new sst.aws.StaticSite("SurveyWeb", {
// ...
edge: basicAuth
? {
viewerRequest: {
injection: $interpolate`
if (
!event.request.headers.authorization ||
event.request.headers.authorization.value !== "Basic ${basicAuth}"
) {
return {
statusCode: 401,
headers: { "www-authenticate": { value: "Basic" } }
};
}`,
},
}
: undefined,
});
ハマったポイント
1. sst dev 時に site.url が使えない
sst dev で開発中は CloudFront がデプロイされないため、site.url が http://url-unavailable-in-dev.mode というダミー値になります。Cognito の callback URL にこの値を渡すと Cognito が HTTP URL を拒否してエラーになりました。
解決策: $dev フラグで分岐し、開発時は localhost のみを使うようにしました。
2. transform.cdn の型が合わない
transform.cdn のコールバック引数の型にインデックスシグネチャがなく、origins や orderedCacheBehaviors を追加しようとすると TypeScript エラーになりました。
解決策: as any でキャストして回避しました。SST の内部型定義がまだ追いついていない部分のようです。
const a = args as any;
a.origins = $output(a.origins).apply((existing: unknown[]) => [
...existing,
apiOrigin,
]);
まとめ
SST v3 を使ったインフラ構築のポイントを振り返ります。
-
StaticSiteで S3 + CloudFront 配信がワンライナー: ビルド、デプロイ、環境変数注入まで1つのコンポーネントで完結する -
linkで Lambda とデータベースの接続が簡単: IAM 権限の手書きが不要 -
CloudFront に API Gateway を相乗りさせて CORS を回避:
transform.cdnで CloudFront をカスタマイズすることで実現 -
ステージ × プロファイルで環境分離:
$devや$app.stageを使って環境ごとの分岐がシンプルに書ける -
sst.Secret+ Makefile でシークレット管理: ステージ別のシークレットをコマンド一つで設定
SST v3 は高レベルコンポーネントで大半の設定を隠蔽しつつ、transform で細かい制御もできるバランスの良いフレームワークだと感じました。特にサーバーレスアーキテクチャとの相性は抜群で、インフラの管理コストを大幅に下げてくれます。
感想
かなり便利であるがゆえに、サービス同士のつながりや背景をあまり理解できずともそれなりに構築できてしまいます。特に自分のようなインフラにあまり触れてこなかったような人は、そのサービスをなぜ使うのか、使うとどういう良いことがあるのかといったところは把握しつつ使って行きたいなと思いました。
