本当に動くVPSセットアップ:Node.js、PM2、Nginx、そしてゼロダウンタイムデプロイ
本番環境で実際に使用しているVPSデプロイメントのセットアップ — Ubuntuの堅牢化、PM2クラスターモード、Nginxリバースプロキシ、SSL、そして一度も失敗していないデプロイスクリプト。理論ではなく、動くものだけ。
このブログは月額10ドルの VPS 上で動いている。Vercel でも AWS でもなく、6人のチームが管理する Kubernetes クラスターでもない。Nginx、PM2、そして30秒以内にデプロイするbashスクリプトを備えた1台のUbuntuマシンだ。
他の道も試してきた。Vercel を使った(cronジョブ、永続的な WebSocket、あるいは単にコントロールが必要になるまでは素晴らしい)。AWS を使った(IAMポリシーに半日費やすのが楽しいなら素晴らしい)。結局いつも VPS に戻ってくる。
しかし問題がある:インターネット上のすべての「VPSへデプロイ」チュートリアルはハッピーパスで止まる。Node.js をインストールして node server.js を実行する方法を見せて、それを本番と呼ぶ。そしてサーバーが SSH ブルートフォース攻撃を受け、プロセスマネージャを設定していなかったから深夜3時にプロセスが死に、SSL 証明書が3か月前に期限切れになっている。
これが自分に欲しかったガイドだ。ここにあるすべては実戦テスト済みで、今あなたが読んでいるこのページはまさにこのセットアップで配信されている。
コードではなくセキュリティから始める#
Node.js のことを考える前に、マシンをロックダウンすること。新しい VPS インスタンスはターゲットだ。自動化されたボットはプロビジョニング後数分以内に SSH ポートを叩き始める。
root以外のユーザーを作成する#
adduser deploy
usermod -aG sudo deploySSH 鍵認証を設定する#
ローカルマシンで:
ssh-copy-id deploy@your-server-ipその後、パスワード認証を完全に無効にする:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshdこれを省略すると、数日以内に認証ログに数千回のログイン失敗が表示される。それは被害妄想ではない。パブリックインターネットの日常だ。
UFW でファイアウォールを設定する#
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable以上だ。4つのルール。SSH とウェブトラフィックのみが通過する。
Fail2Ban#
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local/etc/fail2ban/jail.local を編集する:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600sudo systemctl enable fail2ban
sudo systemctl start fail2banSSH の試行に3回失敗すると1時間バンされる。Fail2Ban が1日で数百のIPをブロックするのを見てきた。効果がある。
自動セキュリティアップデート#
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgradesサーバーがセキュリティパッチを自動インストールするようになる。忘れることが一つ減る。
Node.js:apt ではなく NVM を使う#
あらゆるチュートリアルで見かける:sudo apt install nodejs。やめること。
Ubuntu のパッケージリポジトリは古い Node.js バージョンを配布している。NodeSource の PPA でさえ遅れを取る。そして異なるプロジェクトで Node 20 と Node 22 を切り替える必要があるとき、行き詰まる。
NVM がこれを解決する:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*確認する:
node -v # v22.x.x または現在のLTS
npm -v非自明なヒント:NVM でグローバルパッケージ(PM2 など)をインストールすると、その Node バージョンに紐付けられる。nvm use でバージョンを切り替えると、グローバルパッケージが消える。サーバーではデフォルトを設定して固定すること:
nvm alias default 22これで痛い目に遭ったのはちょうど1回。1回で十分だった。
PM2:その価値を証明するプロセスマネージャ#
PM2 は「デプロイ済み」と「本番対応」の違いだ。プロセス管理、クラスタリング、ログローテーション、クラッシュ時の自動再起動、スタートアップスクリプトを処理する。無料で。
インストールとセットアップ#
npm install -g pm2エコシステム設定#
CLI フラグでアプリを起動してはいけない。ecosystem.config.js ファイルを使う。バージョン管理され、再現可能で、自己文書化されている。
// ecosystem.config.js
module.exports = {
apps: [
{
name: "akousa",
script: "node_modules/.bin/next",
args: "start -p 3002",
cwd: "/var/www/akousa.net",
instances: 2,
exec_mode: "cluster",
max_memory_restart: "500M",
env: {
NODE_ENV: "production",
PORT: 3002,
},
// グレースフルシャットダウン
kill_timeout: 5000,
listen_timeout: 10000,
wait_ready: false,
// ロギング
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "/var/log/pm2/akousa-error.log",
out_file: "/var/log/pm2/akousa-out.log",
merge_logs: true,
// 障害時の自動再起動
autorestart: true,
max_restarts: 10,
min_uptime: "10s",
// 本番環境ではウォッチしない
watch: false,
},
],
};重要な選択を説明する:
instances: 2("max" ではなく): 1〜2コアの小さな VPS では、"max" は賢そうに聞こえるがビルド中にリソースを奪い合うプロセスを生成する。2インスタンスならゼロダウンタイムリロードが可能で、余裕も残る。4コア以上のマシンなら、"max" を使って構わない。
exec_mode: "cluster": これがゼロダウンタイムリロードを可能にする。クラスターモードなしでは、pm2 reload は単なるおしゃれなリスタートだ。クラスターモードでは、PM2 がインスタンスを1つずつ再起動する。アプリが完全にオフラインになることはない。
max_memory_restart: "500M": Next.js アプリにメモリリークがある? PM2 がサーバーを OOM キルする前に再起動する。これで深夜2時のアラートから何度も救われた。
kill_timeout: 5000: PM2 が強制キルする前にアプリに処理中のリクエストを完了させるための5秒。デフォルト(1600ms)はデータベース接続を持つアプリには短すぎる。
watch: false: 本番環境で watch: true のまま放置している人を見たことがある。PM2 はログファイルが変更されるたびにアプリを再起動する。アプリが再起動ループに入る。やめること。
スタートアップスクリプト#
PM2 を再起動後も存続させる:
pm2 startup systemd
# 出力されたコマンドをコピーして実行する
pm2 saveこれで systemd サービスが生成される。サーバー再起動後、アプリは自動的に復帰する。テストすること。サーバーを再起動して確認すること。仮定してはいけない。
ログローテーション#
ログはいずれディスクを食いつぶす。ローテーションモジュールをインストールする:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress trueファイルあたり最大50MB、ローテーションファイルを7つ保持、古いものを圧縮。これがなければ、中程度のトラフィックのアプリで /var/log が3週間で25GBのディスクを埋めるのを見たことがある。
Nginx:思った以上のことをするリバースプロキシ#
「なぜ Node.js をポート80で直接公開しないの?」
Nginx は Node.js がサイクルを無駄にすべきでないものを処理するからだ:SSL ターミネーション、静的ファイル配信、gzip 圧縮、リクエストバッファリング、接続制限、遅いクライアントのグレースフルな処理。C で書かれ、この目的のために作られている。
インストール#
sudo apt install nginx -y設定#
# /etc/nginx/sites-available/akousa.net
upstream node_app {
server 127.0.0.1:3002;
keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name akousa.net www.akousa.net;
# すべての HTTP を HTTPS にリダイレクト
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name akousa.net www.akousa.net;
# SSL(Certbot が管理 — これらの行は自動的に追加される)
ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# セキュリティヘッダー
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Gzip 圧縮
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
application/wasm;
# プロキシ設定
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
# ヘッダー
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket サポート(必要な場合)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# タイムアウト — 余裕があるが無限ではない
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# バッファリング — Nginx に遅いクライアントを処理させる
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
}
# Next.js の静的アセット — Nginx に直接配信させる
location /_next/static/ {
alias /var/www/akousa.net/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
# パブリック静的ファイル
location /static/ {
alias /var/www/akousa.net/public/static/;
expires 30d;
access_log off;
}
# ドットファイルへのアクセスをブロック
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}有効にする:
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxリロード前に必ず nginx -t を実行すること。 以前、壊れた設定をプッシュして構文チェックを省略したためサイトをダウンさせたことがある。nginx -t の5文字で30分のパニックデバッグを避けられたはずだ。
この設定でほとんどのチュートリアルが見落としている点:
upstream ブロックと keepalive 64: Nginx はリクエストごとに新しいTCP接続を開く代わりに、Node.js バックエンドへの接続を再利用する。負荷がかかるときにこれが重要になる。
proxy_buffering on: Nginx は Node.js からのレスポンス全体をメモリに読み込み、クライアントが処理できる速度で送信する。これがなければ、3G回線の遅いクライアントが Node.js のワーカーを占有する。
_next/static/ の直接配信: これらはハッシュ化された不変のアセットだ。Nginx に365日のキャッシュヘッダー付きでディスクから配信させる。Node.js プロセスがこれに時間を浪費すべきではない。
5分で SSL#
Let's Encrypt が SSL を解決した。2026年にまだ証明書に金を払っているなら、やめること。
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.netCertbot がメールアドレスを聞き、ToS への同意を求め、Nginx の設定に SSL ディレクティブを自動的に追加する。以上だ。
自動更新を確認する#
Certbot は1日2回チェックし、有効期限の30日以内に証明書を更新する systemd タイマーをインストールする:
sudo systemctl list-timers | grep certbot更新が動作するかテストする:
sudo certbot renew --dry-runドライランが成功すれば、SSL について二度と考える必要はない。失敗する場合、通常はポート80がブロックされている(UFW ルールを確認)か Nginx が動いていない。
ハマったこと:Certbot を実行する前に Nginx をセットアップする場合、最初は HTTPS リダイレクトなしでサーバーブロックがポート80をリッスンしていることを確認すること。Certbot は HTTP-01 チャレンジのためにポート80に到達する必要がある。Certbot が正常に実行された後でリダイレクトを追加する。
デプロイスクリプト#
これは本番環境にプッシュするたびに実行されるスクリプトだ。CI/CD プラットフォームなし、GitHub Actions なし。SSH と bash だけだ。
#!/bin/bash
# deploy.sh — ほぼゼロダウンタイムのデプロイメント
set -euo pipefail
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
log "=== Deploy started ==="
cd "$APP_DIR"
# 最新コードをプル
log "Pulling latest changes..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
# 依存関係をインストール
log "Installing dependencies..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
# ビルド
log "Building application..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
if [ $? -ne 0 ]; then
log "ERROR: Build failed. Aborting deploy."
exit 1
fi
# PM2 をリロード(クラスターモードでゼロダウンタイム)
log "Reloading PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
# リトライ付きヘルスチェック
log "Running health check..."
for i in $(seq 1 $MAX_RETRIES); do
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
log "Health check passed (HTTP $HTTP_CODE)"
log "=== Deploy completed successfully ==="
exit 0
fi
log "Health check attempt $i/$MAX_RETRIES (HTTP $HTTP_CODE). Retrying in ${RETRY_INTERVAL}s..."
sleep $RETRY_INTERVAL
done
log "ERROR: Health check failed after $MAX_RETRIES attempts"
log "Rolling back to previous PM2 state..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1実行可能にする:
chmod +x deploy.shローカルマシンからデプロイする:
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"このスクリプトの重要な決定:
set -euo pipefail: スクリプトはどんなエラーでも即座に終了する。これがなければ、失敗した npm install が黙ってビルドステップに進み、デバッグに20分浪費する謎のエラーが出る。
ビルド前の rm -rf .next: Next.js にはたまに古い出力を生成するビルドキャッシュがある。ソースコードが更新されているのにページが古いコンテンツを表示するというバグに一度遭遇した。ビルドディレクトリの削除はビルドに15秒ほど追加するが、確実に新しい出力を保証する。
pm2 restart ではなく pm2 reload: これがゼロダウンタイムの部分だ。クラスターモードでは、reload はローリングリスタートを行う。更新されたコードで新しいインスタンスを起動し、準備ができるのを待ち、古いインスタンスをグレースフルにシャットダウンする。実行中のインスタンスがゼロになる瞬間はない。
リトライ付きヘルスチェック: Next.js は再起動後のウォームアップに数秒かかる。スクリプトは最大30秒(10回リトライ x 3秒)待ち、アプリが HTTP 200 で応答するかチェックする。応答しなければ何かが問題で、ユーザーからではなく即座に知る必要がある。
失敗時のロールバック: すべてのリトライ後にヘルスチェックが失敗した場合、スクリプトは PM2 を再起動する(最後に保存された状態をロードする)。完璧なロールバックではないが、サーバーを壊れた状態に放置するよりはましだ。
深夜2時にものが壊れたとき#
まさにこのセットアップで実際にデバッグしたものがこちらだ:
「サイトが落ちている」#
最初に実行するコマンド:
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log10回中9回、pm2 logs が何が起こったかを即座に教えてくれる。環境変数の欠落、データベース接続の失敗、または未処理のPromiseリジェクション。
「メモリが増え続ける」#
pm2 monitこれでプロセスごとのCPUとメモリのライブダッシュボードが得られる。メモリが横ばいにならず着実に増加しているなら、リークがある。エコシステム設定の max_memory_restart 設定がセーフティネットだ。PM2 がサーバーをダウンさせる前にプロセスを再起動する。
より深い調査のために:
pm2 describe akousaこれでアップタイム、再起動回数、メモリスナップショットが表示される。過去24時間に47回の再起動があれば、それがヒントだ。
「SSL証明書が期限切れ」#
sudo certbot certificatesすべての証明書と有効期限を一覧表示する。自動更新が失敗した場合:
sudo certbot renew --force-renewal
sudo systemctl reload nginx「ディスク容量が満杯」#
df -h
du -sh /var/log/*
pm2 flushpm2 flush はすべての PM2 ログファイルを即座にクリアする。ログローテーションを設定していなかったら(設定するように言ったのに)、ここで痛みを感じる。
毎朝実行するコマンド#
ssh deploy@akousa.net "pm2 status && df -h / && uptime"1行で3つのこと:プロセスは動いているか、ディスクは大丈夫か、サーバーは過負荷か。2秒で済む。ユーザーより先に問題を捕捉する。
ほとんどのガイドが教えてくれないこと#
ビルドステップが最大の脆弱性だ。 1GB RAMの VPS では、Next.js アプリの npm run build が800MB以上のメモリを消費する可能性がある。ビルド中に PM2 がアプリを2インスタンスで実行していると、OOM になる。解決策:スワップファイル(最低2GB)を使うか、ビルド中にアプリを停止して数秒のダウンタイムを受け入れる。私はスワップを使っている。
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabインストールコマンドの --legacy-peer-deps は解決策ではなくコードスメルだ。 使っているのは依存関係ツリーの一部のパッケージがピア依存関係の範囲を更新していないからだ。数か月ごとに削除を試みている。いつかうまくいくだろう。それまでは、出荷する。
デプロイスクリプトをゼロからテストすること。 新しいサーバーにリポジトリをクローンし、すべてのステップを手動で実行する。デプロイスクリプトに隠れている「自分のマシンでは動く」問題の数は恥ずかしいほどだ。やったとき3つの問題を見つけた。グローバルパッケージの欠落、間違ったファイルパーミッション、以前の手動セットアップのおかげでしか存在しなかったパス。
サーバーのIPを SSH 設定に入れること。 IPアドレスを入力するのをやめる:
# ~/.ssh/config
Host akousa
HostName 69.62.66.94
User deploy
IdentityFile ~/.ssh/id_ed25519これで ssh akousa だけで済む。小さなことが積み重なる。
完全チェックリスト#
完了と言う前に:
- sudo アクセス付きの非rootユーザー
- SSH 鍵認証のみ、パスワード認証無効
- UFW 有効化、必要なポートのみ開放
- Fail2Ban で SSH を保護
- 自動セキュリティアップグレード有効
- NVM 経由で Node.js をインストール
- PM2 でアプリをクラスターモードで実行
- PM2 スタートアップスクリプト設定済み(再起動後も存続)
- PM2 ログローテーション インストール済み
- 適切なヘッダー付き Nginx リバースプロキシ
- Let's Encrypt による SSL、自動更新付き
- ヘルスチェック付きデプロイスクリプト
- スワップファイル設定済み(ビルドの余裕のため)
- テスト済み:サーバーを再起動してすべてが復帰するか確認
最後の項目が人々が省略するものだ。その人にならないこと。サーバーを再起動し、60秒待ち、アプリが稼働しているか確認する。稼働していなければ、スタートアップスクリプトの設定が間違っており、最悪のタイミングでそれを知ることになる。
これは「エンタープライズグレード」か?#
いいえ。それがポイントだ。
このセットアップは月額10ドル未満でこのブログを確実に配信している。1つのコマンドで30秒でデプロイされる。すべてのパーツを理解している。何かが壊れたとき、どこを見ればいいか正確にわかる。
Docker を使えるか? もちろん。Kubernetes を使えるか? 技術的には。ステージング環境とカナリアデプロイメントを備えた完全な CI/CD パイプラインをセットアップできるか? 絶対に。
しかし学んだのは、最良のインフラは実際に理解でき、深夜2時にデバッグでき、プロジェクトの収益以上のコストがかからないものだということだ。個人サイト、SaaS の MVP、小さなスタートアップには、これがそのセットアップだ。
まず出荷する。必要になったらスケールする。そして必ず、必ず、新しいサーバーでデプロイスクリプトをテストすること。