0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Todo アプリ(Next.js)を ECS Fargate でデプロイしてみた(アプリ作成編)

Last updated at Posted at 2025-08-03

はじめに

ローカルで Docker を用いて WEB アプリケーションを作成していて、どうやってデプロイするんだろうと思ったことはないですか?

私自身、Vercel を使ったデプロイ経験はありますが、インフラから環境構築を行うデプロイは未経験でした。

今回 Docker を用いて作成した簡易な Todo アプリを題材に、Terraform を使って AWS 上にインフラ環境をゼロから構築する手順をまとめてみました。

最終的に作成するアーキテクチャ

architect.png

筆者の開発環境

  • 使用パソコン: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
frontend/prisma/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 を作成

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 を作成

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 を作成

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 を作成

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 を作成

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

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 でラップしただけです。

app/layout.tsx
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 リクエストに対応できるように作成

app/api/todos/route.ts
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 リクエストを受け付けるために作成

app/api/todos/[id]/route.ts
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

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

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

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

frontend/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 を作成

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
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 による機密情報管理

ここまでご覧いただきありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?