はじめに
ローカルで Docker を用いて WEB アプリケーションを作成していて、どうやってデプロイするんだろうと思ったことはないですか?
私自身、Vercel を使ったデプロイ経験はありますが、インフラから環境構築を行うデプロイは未経験でした。
今回 Docker を用いて作成した簡易な Todo アプリを題材に、Terraform を使って AWS 上にインフラ環境をゼロから構築する手順をまとめてみました。
最終的に作成するアーキテクチャ
筆者の開発環境
- 使用パソコン:m1mac
- エディタ:vscode
各種バージョン
nodejs v24.3.0
bun v1.1.7
docker v28.1.1
Todo アプリの仕様
主な機能
- ユーザー認証(NextAuth.js)
- Todo 項目の作成・削除
- 完了/未完了の切り替え
技術的な特徴
- AppRouter を使用
- TypeScript
- Prisma
- Docker
フォルダ構成
Project-Root
├── docker-compose.yml #今回作成するファイル
├── frontend/ #今回作成するアプリ
└── terraform/
ページ構成
ページ | ファイル名 | 主な機能 |
---|---|---|
ダッシュボード | app/page.tsx | Todo の表示・作成・更新・削除 |
ログイン | app/login/page.tsx | ログイン処理 |
新規アカウント作成 | app/register/page.tsx | 新規アカウント作成 |
API 構成表
API エンドポイント | ファイル名 | メソッド | 機能 |
---|---|---|---|
/api/auth/[...nextauth] | route.ts | GET,POST | NextAuth 認証 |
/api/register | route.ts | POST | 新規アカウント登録 |
/api/todos | route.ts | GET,POST | Todo 一覧取得・作成 |
/api/todos/[id] | route.ts | PUT,DELETE | Todo 更新・削除 |
1.Next.js プロジェクトのセットアップ
~project-file
mkdir frontend
cd frontend
bunx create-next-app@latest .
2.必要なパッケージのインストール
今回使用するパッケージとバージョン
prisma: ^6.11.1
next-auth: ^4.24.11
bcrypt: ^6.0.0
@types/bcrypt: ^6.0.0
@prisma/client: ^6.11.1
@next-auth/prisma-adapter: ^1.0.7
インストール
bun a prisma next-auth bcrypt @prisma/client @next-auth/prisma-adapter @types/bcrypt
3.Prisma のセットアップ
prisma フォルダを作成
mkdir prisma
prisma/schema.prisma を作成
cd prisma
touch schema.prisma
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-arm64-openssl-1.1.x", "debian-openssl-1.1.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String? //メール、パスワード認証用
accounts Account[]
sessions Session[]
todos Todo[] //ユーザーが持つtodoリスト
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Todo {
id String @id @default(cuid())
createdAt DateTime @default(now())
updateAt DateTime @updatedAt
content String
completed Boolean @default(false)
owner User @relation(fields: [ownerId], references: [id])
ownerId String
}
Prisma を呼び出すファイルを作成
app/lib/prisma.ts を作成
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") global.prisma = prisma;
export default prisma;
Prisma クライアントとマイグレーションファイルは、Docker を利用して作成するようにするのでここでは作成しません。
4.認証システムの実装
app/lib/auth.ts を作成
import prisma from "@/lib/prisma";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import bcrypt from "bcrypt";
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("メールアドレスとパスワードを入力してください");
}
// データベースからユーザーを検索
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
// ユーザーが存在しない、またはパスワードが設定されていない場合はエラー
if (!user || !user.password) {
throw new Error("入力内容に誤りがあります");
}
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password
);
if (!isPasswordValid) {
throw new Error("入力内容に誤りがあります");
}
return user;
},
}),
],
session: {
strategy: "jwt",
maxAge: 60 * 30, //30分
updateAge: 60 * 10, //10分
},
secret: process.env.NEXTAUTH_SECRET,
pages: {
signIn: "/login",
// error: '/auth/error', // エラーページのパス (任意),
// newUser: '/auth/new-user' // 新規ユーザー登録ページのパス (任意),
},
callbacks: {
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.sub as string;
}
return session;
},
},
};
5. 認証用の API を作成
app/api/auth/[...nextauth]/route.ts を作成
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
6.ユーザー登録用の API を作成
app/api/register/route.ts を作成
import { NextResponse } from "next/server";
import bcrypt from "bcrypt";
import prisma from "@/lib/prisma";
export async function POST(request: Request) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ message: "メールアドレスとパスワードは必要です" },
{ status: 400 }
);
}
const existngUser = await prisma.user.findUnique({
where: { email },
});
if (existngUser) {
return NextResponse.json(
{ message: "このメールアドレスはすでに登録されています" },
{ status: 409 }
);
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
},
});
return NextResponse.json(
{
message: "ユーザー登録が成功しました",
user: { id: user.id, email: user.email },
},
{ status: 201 }
);
} catch (error) {
console.error("ユーザー登録エラー:", error);
return NextResponse.json(
{ message: "サーバーエラーが発生しました" },
{ status: 500 }
);
}
}
型ファイルを作成
app/lib/next-auth.d.ts を作成
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
} & DefaultSession["user"];
}
}
7.認証情報を共有するプロバイダを設定
app/components/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
interface ProviderProps {
children: React.ReactNode;
}
export function Providers({ children }: ProviderProps) {
return <SessionProvider>{children}</SessionProvider>;
}
app/layout.tsx
簡易なのでほとんどデフォルトのまま、children を provider でラップしただけです。
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Todo app with ECS",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>{children}</Providers>
</body>
</html>
);
}
8.Todo 用の API を作成
app/api/todos/route.ts を作成
GET リクエスト、POST リクエストに対応できるように作成
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import prisma from "@/lib/prisma";
import { authOptions } from "@/lib/auth";
// GETリクエスト: ログインユーザーのTodo一覧を取得
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_request: Request) {
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json({ message: "認証が必要です。" }, { status: 401 });
}
try {
const todos = await prisma.todo.findMany({
where: { ownerId: session.user.id }, // ログインユーザーのTodoのみ取得
orderBy: { createdAt: "desc" },
});
return NextResponse.json(todos, { status: 200 });
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Todo取得エラー:", error);
return NextResponse.json(
{ message: "Todoの取得中にエラーが発生しました。" },
{ status: 500 }
);
}
return NextResponse.json(
{ message: "不明なエラーが発生しました。" },
{ status: 500 }
);
}
}
// POSTリクエスト: 新しいTodoを作成
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json({ message: "認証が必要です。" }, { status: 401 });
}
try {
const { content } = await request.json();
if (!content || content.trim() === "") {
return NextResponse.json(
{ message: "Todoの内容は必須です。" },
{ status: 400 }
);
}
const newTodo = await prisma.todo.create({
data: {
content: content.trim(),
ownerId: session.user.id, // ログインユーザーに紐付けてTodoを作成
},
});
return NextResponse.json(newTodo, { status: 201 });
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Todo作成エラー:", error);
return NextResponse.json(
{ message: "Todoの作成中にエラーが発生しました。" },
{ status: 500 }
);
}
return NextResponse.json(
{ message: "不明なエラーが発生しました。" },
{ status: 500 }
);
}
}
app/api/todos/[id]/route.ts を作成
PUT リクエスト、DELETE リクエストを受け付けるために作成
import { authOptions } from "@/lib/auth"; // NextAuthの設定をインポート
import prisma from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
// PUTリクエスト: 特定のTodoを更新
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json({ message: "認証が必要です。" }, { status: 401 });
}
const params = await context.params;
const todoId = params.id;
const { completed } = await request.json(); // 現時点ではcompletedのみ更新を想定
try {
// Todoの所有者を確認
const existingTodo = await prisma.todo.findUnique({
where: { id: todoId },
});
if (!existingTodo || existingTodo.ownerId !== session.user.id) {
return NextResponse.json(
{ message: "Todoが見つからないか、アクセス権がありません。" },
{ status: 404 }
);
}
const updatedTodo = await prisma.todo.update({
where: { id: todoId },
data: { completed },
});
return NextResponse.json(updatedTodo, { status: 200 });
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Todo更新エラー:", error);
return NextResponse.json(
{ message: "Todoの更新中にエラーが発生しました。" },
{ status: 500 }
);
}
return NextResponse.json(
{ message: "不明なエラーが発生しました。" },
{ status: 500 }
);
}
}
// DELETEリクエスト: 特定のTodoを削除
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
if (!session || !session.user?.id) {
return NextResponse.json({ message: "認証が必要です。" }, { status: 401 });
}
const params = await context.params;
const todoId = params.id;
try {
// Todoの所有者を確認
const existingTodo = await prisma.todo.findUnique({
where: { id: todoId },
});
if (!existingTodo || existingTodo.ownerId !== session.user.id) {
return NextResponse.json(
{ message: "Todoが見つからないか、アクセス権がありません。" },
{ status: 404 }
);
}
await prisma.todo.delete({
where: { id: todoId },
});
return NextResponse.json(
{ message: "Todoを削除しました。" },
{ status: 200 }
);
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Todo削除エラー:", error);
return NextResponse.json(
{ message: "Todoの削除中にエラーが発生しました。" },
{ status: 500 }
);
}
return NextResponse.json(
{ message: "不明なエラーが発生しました。" },
{ status: 500 }
);
}
}
9.各種ページの作成
ダッシュボードページ
app/page.tsx
"use client";
interface Todo {
id: string;
content: string;
completed: boolean;
}
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
const Homepage = () => {
const { data: session, status } = useSession();
const router = useRouter();
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodoContent, setNewTodoContent] = useState<string>("");
const [loadingTodos, setLoadingTodos] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (status === "loading") return;
if (!session) {
router.push("/login");
return;
}
fetchTodos();
}, [session, status, router]);
const fetchTodos = async () => {
setLoadingTodos(true);
setError(null);
try {
const res = await fetch("/api/todos");
if (!res.ok) {
throw new Error("Todoの取得に失敗しました");
}
const data: Todo[] = await res.json();
setTodos(data);
} catch (err: unknown) {
setError((err as Error).message || "Todoの取得中にエラーが発生しました");
} finally {
setLoadingTodos(false);
}
};
const handleAddTodo = async () => {
if (newTodoContent.trim() == "") return;
try {
const res = await fetch("/api/todos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content: newTodoContent }),
});
if (!res.ok) {
throw new Error("Todoの追加に失敗しました");
}
const newTodo: Todo = await res.json();
setTodos((prevTodos) => [...prevTodos, newTodo]);
setNewTodoContent("");
} catch (err: unknown) {
setError((err as Error).message || "Todoの追加中にエラーが発生しました");
}
};
const handleToggleComplete = async (id: string, completed: boolean) => {
try {
const res = await fetch(`/api/todos/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ completed: !completed }),
});
if (!res.ok) {
throw new Error("Todoの更新に失敗しました");
}
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { ...todo, completed: !completed } : todo
)
);
} catch (err: unknown) {
setError((err as Error).message || "Todoの更新中にエラーが発生しました");
}
};
const handleDeleteTodo = async (id: string) => {
try {
const res = await fetch(`/api/todos/${id}`, {
method: "DELETE",
});
if (!res.ok) {
throw new Error("Todoの削除に失敗しました");
}
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
} catch (err: unknown) {
setError((err as Error).message || "Todoの削除中にエラーが発生しました");
}
};
if (status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center">
Loading...
</div>
);
}
if (!session) {
return null;
}
return (
<main className="flex min-h-screen flex-col items-center p-24">
<div className="w-full max-w-md flex justify-between items-center mb-8">
<h1 className="text-4xl font-bold">Todo App</h1>
<div className="flex items-center gap-4">
<p className="text-lg">Hello {session.user?.email || "User"}</p>
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="bg-red-500 text-white rounded px-4 py-2"
>
Logout
</button>
</div>
</div>
<div className="flex mb-4 w-full max-w-md">
<input
type="text"
value={newTodoContent}
onChange={(e) => setNewTodoContent(e.target.value)}
className="border rounded-l px-4 py-2 text-yellow-300 border-yellow-300 flex-grow"
placeholder="Add a new todo"
/>
<button
className="bg-blue-500 text-white rounded-r px-4 py-2"
onClick={handleAddTodo}
>
Add
</button>
</div>
{error && <p className="text-red-500 mb-4">{error}</p>}
{loadingTodos ? (
<p>Loading Todos ...</p>
) : (
<ul className="w-full max-w-md">
{todos.length === 0 ? (
<p className="text-center text-gray-500 ">No todos yet. Add one</p>
) : (
todos.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between bg-gray-800 p-3 rounded mb-2"
>
<span
className={`flex-grow cursor-pointer ${
todo.completed ? "line-through text-gray-500" : ""
}`}
onClick={() => handleToggleComplete(todo.id, todo.completed)}
>
{todo.content}
</span>
<button
onClick={() => handleDeleteTodo(todo.id)}
className="bg-red-600 text-white rounded px-3 py-1 ml-4"
>
Delete
</button>
</li>
))
)}
</ul>
)}
</main>
);
};
export default Homepage;
ログインページ
app/login/page.tsx
"use client";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
const Login = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const result = await signIn("credentials", {
redirect: false,
email,
password,
});
if (result?.error) {
setError(result.error);
} else {
router.push("/");
}
};
return (
<div className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold mb-8">Login</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="border rounded px-4 py-2 text-yellow-300 border-yellow-300 "
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="border rounded px-4 py-2 text-yellow-300 border-yellow-300"
/>
<button
type="submit"
className="bg-blue-500 text-white rounded px-4 py-2"
>
Login
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
</form>
<p className="mt-4 text-sm ">アカウントをお持ちでないですか?</p>
<Link
href={"/register"}
className="text-blue-500 hover:underline transition"
>
登録はこちら
</Link>
</div>
);
};
export default Login;
新規アカウント作成ページ
app/register/page.tsx
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
const Register = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
if (password !== confirmPassword) {
setError("パスワードが一致しません");
return;
}
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
setSuccess("ユーザー登録が完了しました、ログインしてください");
router.push("/login");
} else {
setError(data.message);
}
} catch (error) {
setError(`ネットワークエラーが発生しました。${error}`);
}
};
return (
<div className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold mb-8">Register</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="border rounded px-4 py-2 text-yellow-300 border-yellow-300"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="border rounded px-4 py-2 text-yellow-300 border-yellow-300"
/>
<input
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="border rounded px-4 py-2 text-yellow-300 border-yellow-300"
/>
<button
type="submit"
className="bg-green-500 text-white rounded px-4 py-2"
>
Register
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
{success && <p className="text-green-500 text-sm">{success}</p>}
</form>
<p className="mt-4 text-sm">すでにアカウントをお持ちですか?</p>
<Link
href={"/login"}
className="text-blue-500 hover:underline transition"
>
ログインはこちら
</Link>
</div>
);
};
export default Register;
10.Dockerfile 等の作成
frontend/に Dockerfile を作成
Dockerfile
# ①依存関係をインストールするビルドステージ
FROM --platform=${TARGETPLATFORM:-linux/arm64} node:20 AS builder
WORKDIR /app
COPY package.json bun.lockb ./
RUN npm install --frozen-lockfile
COPY . .
RUN npx prisma generate
RUN npm run build -- --no-lint
# ②最終的なイメージ作成のビルドステージ
FROM --platform=${TARGETPLATFORM:-linux/arm64} oven/bun:1.1.7-slim
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/lib ./lib
ENV NODE_ENV=production
EXPOSE 3000
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT [ "/bin/sh","./entrypoint.sh" ]
CMD [ "bun","run","start" ]
entrypoint.sh
frontend/に entrypoint.sh を作成
#!/bin/sh
set -e
bunx prisma migrate deploy
exec "$@"
マルチステージビルドにし容量を削減するようにしました。
FROM --platform=${TARGETPLATFORM:-linux/arm64}
にしているのは、開発しているパソコンが mac ですがデプロイ先の ECS(Fargate)が linux/amd64 であることから後で ECR にプッシュするときに linux/amd64 に変更するためです。
また、bun を使用したかったため ② で bun を使用してます。
11.docker-compose.yml の作成
プロジェクトのルートに docker-compose.yml を作成します
Project-Root
├── docker-compose.yml #作成
├── frontend/
└── terraform/
もしカレントディレクトリがfrontend/だったら
cd ..
touch docker-compose.yml
services:
db:
image: postgres:15-alpine
container_name: my_postgres_db
restart: always
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
app:
build:
context: ./frontend
container_name: nextjs_todo_app
restart: always
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://user:password@db:5432/db?schema=public
- NEXTAUTH_SECRET=<ランダムに作成してください>
- NEXTAUTH_URL=http://localhost:3000
depends_on:
- db
volumes:
postgres_data:
driver: local
NEXTAUTH_SECRET とは、next-auth が認証する際に使用する秘密鍵となるもので本番運用する際は外部に漏れるとセキュリティ的にアウトです。
ランダムな値の作成ですが、ターミナルで以下を実行して作成できます
openssl rand -base64 32
12.ローカル環境での動作確認
プロジェクトルートで以下を実行します
docker compose up --build
# もしエラーが出る場合には一度ボリュームも含めて削除
docker compose down -v
# エラーがポートの競合であれば docker-compose.ymlのEXPOSEを変更
動作状況
13.まとめと次回予告
今回作成したもの
- Next.js(App Router) + TypeScript
- NextAuth.js による認証システム
- Prisma + PostgreSQL によるデータ管理
- Docker 化による環境の統一
次回予告
次回は、今回作成したアプリ(イメージ)を AWS でデプロイするためのインフラ環境を作成します。
次回扱う内容(予定)
- Terraform の base/app レイヤー分割設計
- VPC とネットワーク設計
- RDS PostgreSQL の構築
- ECR リポジトリの作成
- ECS Fargate クラスターの構築
- Application Load Balancer の設定
- Secrets Manager による機密情報管理
ここまでご覧いただきありがとうございました。