Next.js 電子名刺サイトをSupabaseなしでVPSに移行した話
はじめに
NFCタグ/QRコードで開く電子名刺サイト(Next.js App Router + Supabase)を、社内のVPS(Ubuntu + PostgreSQL)に移行しました。
もともとSupabaseのURL・APIキーが未設定で「どこに本番DBを立てるか」未決定な状態だったため、外部SaaS依存をやめてVPS完結にする方針に切り替え、改修→デプロイ→SSL取得まで一気に通しました。この記事ではその手順とハマりポイントをまとめます。
やったこと
1. アーキテクチャの変更方針
| 項目 | Before(Supabase) | After(VPS完結) |
|---|---|---|
| DB接続 | @supabase/supabase-js |
pg(VPS内蔵PostgreSQL) |
| ファイル保存 | Supabase Storage | VPSローカルディスク + nginx配信 |
| 認証 | Supabase Auth | iron-session(変更なし) |
2. DB接続層の差し替え
src/lib/supabase.ts を廃止し、src/lib/pg.ts を新規作成。
// src/lib/pg.ts
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export default pool;
src/lib/db.ts は全面書き換え。Supabaseの .select() チェーンをSQL文に置き換えました。
// Before(Supabase)
const { data } = await supabase.from('profiles').select('*').single();
// After(pg)
const { rows } = await pool.query('SELECT * FROM profiles LIMIT 1');
return rows[0] ?? null;
3. 保存APIをトランザクションに書き直し
SNS・連絡先・カードなど5種のAPIを、Supabaseの upsert からSQL(BEGIN / COMMIT)に書き換えました。
// src/app/api/admin/socials/route.ts(抜粋)
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM socials');
for (const s of socials) {
await client.query(
'INSERT INTO socials (platform, url, sort_order) VALUES ($1, $2, $3)',
[s.platform, s.url, s.sort_order]
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
4. 画像アップロードをローカルディスクに変更
Supabase Storageへの upload() を、VPS上の public/uploads/ への書き込みに変更。nginxで /uploads/ を直接配信します。
// src/app/api/admin/upload/route.ts(抜粋)
import { writeFile } from 'fs/promises';
import path from 'path';
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const filepath = path.join(process.cwd(), 'public/uploads', filename);
await writeFile(filepath, buffer);
return NextResponse.json({ url: `/uploads/${filename}` });
nginx側では以下を追加:
location /uploads/ {
alias /opt/meishi/public/uploads/;
expires 30d;
}
5. VPSへのデプロイ
rsyncで転送し、サーバー側でビルド→systemd常駐:
# 転送(.env / node_modules / .next 等を除外)
rsync -avz \
--exclude='.DS_Store' --exclude='node_modules/' --exclude='.next/' \
--exclude='**/.env' --exclude='**/.env.local' \
./ kaitai:/opt/meishi/
# サーバー側でビルド
ssh kaitai "cd /opt/meishi && npm ci && npm run build"
# systemdサービスとして起動
sudo systemctl enable --now meishi-web
DNS切替後、Let's EncryptでSSLを取得:
sudo certbot --nginx -d meishi.example.jp --non-interactive --agree-tos \
-m admin@<your-domain> --redirect
ハマったポイント
① NEXT_PUBLIC_* 環境変数はビルド時に焼き込まれる
.env.local をVPSに誤ってrsyncで上書きすると、localhost が本番ビルドに焼き込まれます。
対策: rsyncに必ず --exclude='**/.env*' を入れる。VPS側の .env は初回のみ手動配置し、以後は触らない。
# NG: ローカルの.env.localがVPSを上書き → "Failed to fetch"
rsync -avz ./ kaitai:/opt/meishi/
# OK: .envを除外
rsync -avz --exclude='**/.env' --exclude='**/.env.local' ./ kaitai:/opt/meishi/
② .next を消してから再ビルドしないと env 変更が反映されない
NEXT_PUBLIC_* はビルドキャッシュに含まれるため、env変更後は .next を手動削除してからビルドする必要があります。
ssh kaitai "cd /opt/meishi && rm -rf .next && npm run build"
③ 日本語ファイル名はrsyncで壊れる
macOS(NFD)とLinux(NFC)でUnicode正規化が異なるため、日本語名のファイルはrsync後にアクセスできなくなることがあります(実際に発生)。
対策: ファイル名・ディレクトリ名はすべてASCIIに統一する。
④ サブパスでの静的エクスポートはbasePath設定が必要
今回は結果的にサブドメイン(meishi.example.jp)でデプロイしたため問題なかったですが、/meishi のようなサブパスで配信する場合は next.config.ts に basePath: '/meishi' が必要です。これが未設定だと /_next/ の静的アセットが404になります。
// next.config.ts
const nextConfig: NextConfig = {
basePath: '/meishi', // サブパス配信時のみ必要
};
学び
- Supabaseは便利だが、VPS環境なら内蔵PostgreSQLで十分。 DBとStorageの2役をVPSに集約することで、外部SaaSへの依存と通信コストがなくなる。
-
Next.js App RouterのAPIルート(Route Handlers)はそのままNode.jsの強みを活かせる。
pgを直接使うシンプルな実装で、Supabase SDKを完全に代替できた。 - rsyncの除外ルールは「デプロイスキル」として文書化しておくと再利用しやすい。 複数プロジェクトを同一サーバーに同居させる場合、除外ファイルのパターンが共通化できる。
- (個人の感想)Claude Codeにコードベースを読み込ませてから改修・デプロイまで一気に進めると、手順書の作成とコマンドの実行が同時進行できて体感速度が大幅に上がった。