🦌

NextJS+Prismaプロジェクトセルフホスティング話

2025/02/01に公開

はじめに

数か月前に、Vercel上に自分のNextJS/Prisma/RSCを用いて日本語学習サイトをデプロイしました。
https://zenn.dev/chenbj/articles/555a42958b5a3e
でも、最近はセルフホスティングに移行しました。
理由としては、Vercelに不満があるわけではなく、ただAWSの基礎知識やCI/CDについて学びたいと思っています。(筆者はその辺について経験がゼロです)
VercelのServerlessとEdge Functionsのアーキテクチャがモダンだと聞いたので、できるだけVercelと同じ方法でデプロイしようと考えました。
NextJSはセルフホスティングに対してあんまりサポートしていませんが、コミュニティにはsstというツールがあり、それを利用してServerlessアーキテクチャでセルフホスティングすることができると思いますが、試して最後諦めた。理由は二つ:

  1. sstのドキュメントによる、PrismaはServerlessアーキテクチャと相性が悪い(他の処理が必要みたい)
  2. Serverlessアーキテクチャに一回構築経験もない人に対しては難しい

最終的に、初心者に扱いやすいEC2/Github Actions/Dockerのアーキテクチャを選択しました。少し古いなんですが、設定すればオートスケールもできるし、そうなんに重たいではありません。

EC2アーキテクチャの仕組み

EC2を利用するアーキテクチャは比較的にシンプルですが、そもそもこの記事は自分のような初心者向けなので、一応細かい部分まで仕組みを紹介したいと思います。

基盤

まず、NextJSプロジェクトはローカルでnpm run buildして、npm run startすれば、ローカルでアクセスできます。単純化して考えると、ローカルでブルドではなくEC2インスタンスでビルドしてnpm run startを実行します。EC2インスタンスは簡単に言うとサーバーです、そのサーバーをアクセスするとNextJSアプリを利用できます。

ですが、ローカルとサーバーのアクセスはいくつか異なるところがあります:

  1. ドメイン
    もちろん、自分のウェブサイトに対応するドメインを購入することが必要です。AWSでも購入できるし、購入したら、DNSを設定し、ドメインをインスタンスの外部IPに紐つければ完了です。
  2. HTTPS
    インタネット上公開されるサイトはHTTPSで通信する必要があります。そのため、certbotを利用してHTTPS証明書を取得することが必要です。
  3. ポート(接続方針)
    ローカルの場合は、3000ポート経由で直接アクセスしますが、サーバーの場合それほど簡単ではない。
    まず、本番の場合ポートを指定しないです、例えhttps://japanese-memory.com/
    このようなポートを指定しない場合、どのポートにアクセスしますか?
    これはプロトコルにより、HTTPSの場合は443ポート、HTTPの場合は80ポートです。
    ただ、npm run startあと、うちのアプリサーバーは3000ポートでサービスを提供しています、異なるポートだからアプリサーバーにアクセスできません。

だから、nginxが必要です、nginxサーバーは主に二つ役割があります:

  1. 443ポートをリッスンし、HTTPS証明書を提供します
  2. 443へのリクエストを3000ポートに転送します

ここで、最小限の必要なツールと仕組みは全部紹介しました。以上のツールだけでもデプロイは完了できますが、一般的にDockerを利用してコンテナ化を行い、Github Actionsなどツールで自動化を実現することが多い。

Docker

この場合、Dockerは不可欠わけではないけど、作業を簡潔にする非常に便利なツールです。
例えば、上記のように不可欠なnginxをインストールしたい場合でも、OSごとにインストールコマンドが異なります。また、EC2は様々な種類のOSがあります。そのため、うちのアプリをあるEC2から別のOSを持つEC2に移行したいの場合、手順が異なるため管理が面倒です。

でも、Dockerを使えば、必要な環境を宣言するだけでよく、インストール作業はDockerに担当してくれます。

うちの場合、Dockerを利用する方法として、docker-compose.ymlファイルにどのコンテナを利用するかを記載します。
まずは、もちろんNextJSアプリです、このコンテナについては、以下の二つを記載する必要があります。

  1. 必要な環境
    一般的に、NextJSアプリを起動するためならNodeだけでいいです
  2. 起動コマンド
    うちの場合は、npm install,prisma generate, npm run build, npm run startです。
    これらのコマンドはアプリごとに異なるため、docker-compose.ymlファイルで記載ではなく、Dockerfileファイルで記載します。docker-compose.ymlファイルは、コンテナに関する基本な設定のみを記載します。

あとはnginxのコンテナ、nginxのimageを利用して、nginxのサービスが立ち上げます。
具体的にnginxに何をさせるかは、nginx.confファイルで宣言すればいいです(443をリッスン、HTTPS証明書を提供、リクエストを3000に転送)。

最後注意すべき点は、コンテナは独立なサブのOSみたいなものなので、外部(例えばEC2)のファイルはアクセスできないです。何か利用したい場合、外部からコンテナにコピーする必要があります。

まずNextJSアプリのコードベースもコピーする必要があり、一般的にDockerfileファイルで全部コピーを設定しています、何か不要なもの(例えばnode_modulesなど)を.dockerignoreで書いてあります。

あと、certbotはEC2でHTTPS証明書を生成したあと、コンテナ内のnginxを利用したいため、nginxのコンテナにコピーすることが必要です。具体的にはdocker-compose.yml内でvolumesという設定項目を利用してコピーを行います:

nginx-proxy:
    image: nginx:latest
    container_name: nginx-proxy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - /etc/letsencrypt/live/japanese-memory.com:/etc/letsencrypt/live/japanese-memory.com:ro
      - /etc/letsencrypt/archive/japanese-memory.com:/etc/letsencrypt/archive/japanese-memory.com:ro
      - /etc/letsencrypt/privkey.pem:/etc/letsencrypt/privkey.pem:ro
    depends_on:
      - next-app

Github Actions

Github ActionsはただのCI/CDの手段の一つです、役割はgit pushあと、自動的にEC2に接続し、デプロイします。
Github Actionsを利用したいなら、二つステップが必要です。

  1. 環境変数・シークレットを設定する
    GithubのリポジトリのSettings内にあるActionsのSecrets and variablesセクションで設定を行うことができます。
    シークレットとvariablesは基本役割が一致しているけど、シークレットはGithubから誰でも見えないです、variablesは見えます。あと参照する際にはそれぞれsecrets.xxxとvars.xxxとして使い分けます。
  2. deploy.ymlファイルを作ろ
    具体的にどうやってEC2に接続し、どうやってデプロイはdeploy.ymlファイルで定義します。
    まず、EC2に接続用なSSH_PRIVATE_KEY、EC2_HOSTはSecretsに書いてありますが、実際接続前、Github Actionsのサーバーに格納する必要があります。
    あと、NextJSブルド際、たくさん環境変数、シークレットが必要があります。それらは今すべてGithubのSecrets and variablesに書いてありますが、自動的に利用されるわけではないです、deploy.ymlファイル内で、すべての環境変数を含む.envファイルをEC2で作ろうことを明らかに宣言する必要があります。
- name: EC2で.env.localを生成する
        run: |
          ssh -i ~/.ssh/id_rsa ec2-user@${{ secrets.EC2_HOST }} << 'EOF'
            echo 'POSTGRES_PRISMA_URL="${{ secrets.POSTGRES_PRISMA_URL }}"' > ~/japanese-memory-rsc/.env.local
            echo 'NEXTAUTH_URL="${{ vars.NEXTAUTH_URL }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'AUTH_GITHUB_ID="${{ vars.AUTH_GITHUB_ID }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'AUTH_GITHUB_SECRET="${{ secrets.AUTH_GITHUB_SECRET }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'GOOGLE_CLIENT_ID="${{ vars.GOOGLE_CLIENT_ID }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'AUTH_SECRET="${{ secrets.AUTH_SECRET }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'NEXT_PUBLIC_SUBSCRIPTION_KEY="${{ secrets.NEXT_PUBLIC_SUBSCRIPTION_KEY }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'NEXT_PUBLIC_REGION="${{ vars.NEXT_PUBLIC_REGION }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY }}"' >> ~/japanese-memory-rsc/.env.local
          EOF

最後はEC2に接続し、NextJSの最新のコードを取得し、dockerコマンドを利用して、ウェブサイトサービスをアップデートします:

- name: Deploy to EC2
        run: |
          ssh -i ~/.ssh/id_rsa ec2-user@${{ secrets.EC2_HOST }} << 'EOF'
            cd ~/japanese-memory-rsc
            git pull origin main
            docker-compose down
            sudo docker system prune -a -f  # ディスク容量が少ないので、キャッシュをクリアすることが必要
            docker-compose up --build -d
          EOF

手順と注意事項

仕組みは一応紹介しましたが、具体的な手順と注意事項はまだ紹介していないので、次から紹介します。

ドメイン購入

AWSでドメインを購入します、筆者のはjapanese-memory.comです。
あと、DNSの設定だけです。

EC2環境構築

他のクラウドサービスの方が安いかもしれませんが、AWSは一番流行ってるから、AWSのEC2を選択しました。メモリは4GB(t2.medium)にしました、それより小さいとNextJSプロジェクトのブルドは難しくなリます。

インスタンス作成したら、基本的な環境構築が必要です。Nodeなど環境はDockerに任せるけど、Docker本体とGitは自分でインストール必要があります。
選んだOSはAmazon Linux 2023ですので、以下のコマンドはAmazon Linux 2023に適用されます。
AWSにログインして、EC2に接続して、以下コマンドを実行することが必要です。

# dockerをインストール
sudo dnf update -y
sudo dnf install -y docker
# dockerをスタートする
sudo systemctl start docker
# dockerを自動スタートする
sudo systemctl enable docker
# docker-composeをインストール
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
# gitをインストール
sudo dnf install -y git
# 自分のプロジェクトをダウンロードする
git clone https://github.com/chenbj5515/japanese-memory-rsc.git
# certbotをインストール(HTTPS証明書作成用)
sudo dnf install -y certbot python3-certbot-nginx
# HTTPS証明書を保存する用場所を作ります
sudo mkdir -p /etc/letsencrypt
# HTTPS証明書を作成する
sudo certbot certonly --standalone -d japanese-memory.com

こっち注意すべきのことは、証明書の有効期限は90日です、迎えたら更新することが必要です。
これ忘れやすいから、自動更新の設定が必要です。

# cronieをインストール(自動更新用)
sudo dnf install cronie
# cronieを起動します
sudo systemctl start crond
# cronieを自動で起動します
sudo systemctl enable crond
# 実行したら、vimみたいなエディタを使ってスクリプトを作成します
sudo crontab -e

エディタに入ったら、まずiを押して、0 3 * * 1 certbot renew --quiet && docker-compose down && sudo docker system prune -a -f && docker-compose up --build -d をペーストします。あとescを押して、:wqを入力して、enterを押して保存して終了します。

EC2の設定について、もう一つ必ず行うべきことがあります。
前もう言いましたけど、HTTPSで通信する場合は443ポートにアクセスします。ただ、デフォルトででは、EC2インスタンスは443ポートを開放されてないです、EC2のセキュリティグループで443ポートを開放するというインバウンドルールを追加することが必要です。

AWSにログインして、次はコンテナの実行ですが、まだDocker関係の定義ファイルを作ってないので、それを作ります。

Docker関連の設定ファイル

EC2/Docker/Github Actionsという方式でデプロイしたいなら、以下設定ファイルを追加することが必要です。

japanese-memory-rsc
├── Dockerfile                   // Dockerのビルド設定ファイル
├── docker-compose.yml           // Docker Composeの設定ファイル
├── .dockerignore                // Dockerビルド時に無視するファイルを指定
├── nginx
│   └── nginx.conf               // Nginxの設定ファイル
├── .github
│   └── workflows
│       └── deploy.yml           // GitHub Actionsによるデプロイ設定
└── ...                          // srcなどプロジェクトの他の部分

docker-composeは紹介されなかったけど、実際はただいくつかコンテナを一気に起動する用のコマンドだと思います。

docker-compose.yml
services:
  next-app:  # 最初に起動したいコンテナサービス(NextJSサービス)
    build:
      context: .  # 現在のディレクトリをビルドコンテキストとして使用
      dockerfile: Dockerfile  # 使用するDockerfileを指定
    container_name: next-app  # コンテナの名前を指定
    restart: always  # コンテナが停止した場合、自動的に再起動
    ports:
      - "3000:3000"  # ホストの3000番ポートをコンテナの3000番ポートにマッピング
    environment:
      - NODE_ENV=production  # 環境変数を設定し、プロダクションモードで実行

  nginx-proxy:  # 次に起動したいコンテナサービス(Nginxサービス)
    image: nginx:latest  # 最新バージョンのNginxイメージを使用
    container_name: nginx-proxy  # Nginxコンテナの名前を指定
    restart: always  # コンテナが停止した場合、自動的に再起動
    ports:
      - "80:80"    # HTTP用の80番ポートを公開
      - "443:443"  # HTTPS用の443番ポートを公開
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro  # Nginxの設定ファイルをマウント(読み取り専用)
      - /etc/letsencrypt/live/japanese-memory.com:/etc/letsencrypt/live/japanese-memory.com:ro  # SSL証明書(Let’s Encrypt)のライブディレクトリをマウント
      - /etc/letsencrypt/archive/japanese-memory.com:/etc/letsencrypt/archive/japanese-memory.com:ro  # SSL証明書のアーカイブディレクトリをマウント
      - /etc/letsencrypt/privkey.pem:/etc/letsencrypt/privkey.pem:ro  # SSLの秘密鍵をマウント
    depends_on:
      - next-app  # Nginxはnext-appサービスに依存しており、next-appが起動してからNginxが起動

Dockerfileには、このNextJSアプリに必要な環境、EC2からコピーするファイル、そしてサービスを起動するためのすべてのコマンドが記録されます。

Dockerfile
# 1. Node.js をベースイメージとして使用
FROM node:18-alpine

# 2. 作業ディレクトリを設定
WORKDIR /app

# 3. すべてのコードをコピー(要らない部分は.dockerignoreに記載してください)
COPY . .

# 4. 依存パッケージをインストール
# package.json内で"postinstall": "prisma generate" というスクリプトがあるため、prisma generateも不要
RUN npm install

# 5. ビルド
RUN npm run build

# 6. サービスを起動する
CMD ["npm", "run", "start"]

nginxのconfigファイルは、nginxサーバーを起動する際、なんの動作をするかを記載しています。

nginx.conf
# HTTP から HTTPS へのリダイレクト設定
server {
    listen 80;
    server_name japanese-memory.com;

    # すべてのHTTPリクエストをHTTPSにリダイレクト
    return 301 https://$host$request_uri;
}

# HTTPSサーバー設定
server {
    listen 443 ssl;
    server_name japanese-memory.com;

    # SSL証明書と秘密鍵のパスを指定
    ssl_certificate /etc/letsencrypt/live/japanese-memory.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/japanese-memory.com/privkey.pem;

    # Next.js アプリケーションへのプロキシ設定
    location / {
        proxy_pass http://next-app:3000;  # Next.js アプリのコンテナにリバースプロキシ
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Github Actions設定

仕組みのセクションで話しましたが、主には環境変数の管理とCI/CDを定義するdeploy.ymlファイルの作成二つです。
ビルド際に環境変数は必要だけど、.envファイルがアップロードしないから、EC2で取得できないです。
だから、環境変数を全部GithubのリポジトリのSettings内にあるActionsのSecrets and variablesセクションで設定することが必要です。

そのあと、deploy.ymlを作って、環境変数をEC2の.envの中に記入します。

deploy.yml
name: Deploy to EC2

on:
  push:
    branches:
      - main  # コードが main ブランチにプッシュされたときにトリガー

jobs:
  deploy:
    runs-on: ubuntu-latest  # GitHub Actions の最新の Ubuntu 環境で実行

    steps:
      - name: リポジトリをチェックアウト
        uses: actions/checkout@v3  # リポジトリのコードをクローン

      - name: SSHキーの設定
        run: |
          mkdir -p ~/.ssh  # SSHディレクトリを作成
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa  # 秘密鍵を保存
          chmod 600 ~/.ssh/id_rsa  # 秘密鍵の権限を設定(読み取り専用)
          ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts  # EC2ホストのSSHキーを追加して信頼性を確保

      - name: .env.localファイルをEC2で作成
        run: |
          ssh -i ~/.ssh/id_rsa ec2-user@${{ secrets.EC2_HOST }} << 'EOF'
            # 環境変数をEC2上の .env.local ファイルに書き込む
            echo 'POSTGRES_PRISMA_URL="${{ secrets.POSTGRES_PRISMA_URL }}"' > ~/japanese-memory-rsc/.env.local
            echo 'NEXTAUTH_URL="${{ vars.NEXTAUTH_URL }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'AUTH_GITHUB_ID="${{ vars.AUTH_GITHUB_ID }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'AUTH_GITHUB_SECRET="${{ secrets.AUTH_GITHUB_SECRET }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'GOOGLE_CLIENT_ID="${{ vars.GOOGLE_CLIENT_ID }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'AUTH_SECRET="${{ secrets.AUTH_SECRET }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'NEXT_PUBLIC_SUBSCRIPTION_KEY="${{ secrets.NEXT_PUBLIC_SUBSCRIPTION_KEY }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'NEXT_PUBLIC_REGION="${{ vars.NEXT_PUBLIC_REGION }}"' >> ~/japanese-memory-rsc/.env.local
            echo 'OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY }}"' >> ~/japanese-memory-rsc/.env.local
          EOF

      - name: EC2へのデプロイ
        run: |
          ssh -i ~/.ssh/id_rsa ec2-user@${{ secrets.EC2_HOST }} << 'EOF'
            cd ~/japanese-memory-rsc  # プロジェクトディレクトリに移動
            git pull origin main  # 最新のコードをプル
            docker-compose down  # 既存のコンテナを停止・削除
            sudo docker system prune -a -f  # 未使用のDockerリソースを削除(-fで自動確認)
            docker-compose up --build -d  # コンテナを再ビルドしてバックグラウンドで起動
          EOF

上にsecrets.SSH_PRIVATE_KEYを利用していますが、SSH_PRIVATE_KEYの取得は言い忘れました。
EC2を入手する際にadmin.pemというファイルがもらえます。その中、EC2にアクセス用のSSH_PRIVATE_KEYを記載しています。

# このコマンドでファイルを読みます(Mac)
cat ~/Downloads/admin.pem
# 見本は以下です、以下を全部(BEGINとEND行も含んでいます)コピーします
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA4xG7...
-----END RSA PRIVATE KEY-----

あと、GithubのSecrets and variablesでSSH_PRIVATE_KEYというsecretsを設定したら、deploy.ymlファイルにsecrets.SSH_PRIVATE_KEYというコードが有効になります。

Authの設定

筆者のウェブサイトはGoogleログインとGitHubログインに対応しています、新しいドメインを利用する際に、GithubとGoogleの開発者プラットフォームで新しいドメインに対する設定を追加することが必要です。

まとめ

簡素なアーキテクチャーだけど、詳しく解説すると意外と知識点がたくさんありますね。
でも、仕組みだけわかったらいいです、具体的な設定方法や不具合はAIに相談して解決できると思います。
https://github.com/chenbj5515/japanese-memory-rsc/
https://japanese-memory.com/

移行したら、全ルートも速やかにアクセスできるので、キャッシュの方は問題なさそうですが、Server Actionsのストリーミングはうまく機能しなくなった。以前は日本語文を入力すると、翻訳結果や読み方は逐次返ってきていますが、今はすべて揃ってからまとめて返されます。

それはユーザー体験に大きく損なうから、めちゃくちゃ気になります。調査して解決したあと、もし記事を書く価値があれば、別の記事にまとめます。ないなら、ここで追記します。

あと、DBはまだVercelに提供するものを利用しています、後で別のDBサービスを探します。

アーキテクチャーとしては、簡素すぎだから、次はオートスケールを追加します(今必要がないけど)。

さらに、Serverlessアーキテクチャーに触りたいです。

Discussion