n8n を知ったのは「Zapier が高すぎる」という不満からでした
個人開発で複数のサービスをまたいだ自動化を組もうとしたとき、最初に当たった壁は技術ではなく費用でした。Zapier の有料プランは月額 $29 から、Make は $16 から。動かしたいフローが 3 本しかない段階では、どうしても見合いません。
そこで出会ったのが n8n(en-eight-en と読みます)でした。オープンソースで、セルフホストすれば費用はサーバー代だけ。AI ノードが充実してからは、手元の自動化の中心に置いています。
ただ、立ち上げの過程では素直に動かない箇所がいくつもありました。ここでお伝えするのは、その詰まりどころと、実際に手を動かして確かめた対処です。
Zapier / Make と分かれるのは「データの自由度」でした
n8n はフェアコードライセンスで公開されているワークフロー自動化ツールです。GUI でフローを組み立てられて、400 以上のサービスと接続できます。
三者の違いを、選ぶときに効いた観点だけ並べます。
| 観点 | n8n(セルフホスト) | Zapier | Make |
| 費用の伸び方 | サーバー代のみ。実行回数で増えない | タスク数に連動 | オペレーション数に連動 |
| 任意コードの実行 | JavaScript / Python をノード内で自由に | 制約あり | 制約あり |
| データの置き場所 | 自分のサーバー | ベンダー側 | ベンダー側 |
| 運用の手間 | 更新・バックアップは自分の責任 | 不要 | 不要 |
| 2 ステップの単純自動化 | やや冗長 | 最短 | 短い |
正直に書くと、単純な 2 ステップなら Zapier のほうが早い場面があります。n8n が効いてくるのは、条件分岐とデータ変換と AI 呼び出しが絡み合ってからです。逆に言えば、そこに至らないうちにセルフホストを始めると、運用の手間だけが先に来ます。
セルフホスト環境の構築
Docker Compose の最小構成
# docker-compose.yml
services:
n8n:
image: docker.n8n.io/n8nio/n8n:2.35.5
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
environment:
- N8N_HOST=your-domain.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://your-domain.com/
- GENERIC_TIMEZONE=Asia/Tokyo
- TZ=Asia/Tokyo
# 鍵は必ず自分で決めて .env から渡す(後述)
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# リバースプロキシ 1 段を信頼して X-Forwarded-* を読ませる
- N8N_PROXY_HOPS=1
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
# 鍵を作って .env に置く(この 1 行を飛ばすと後で泣きます)
echo "N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)" > .env
chmod 600 .env
docker compose up -d
docker compose logs -f n8n
初回は http://localhost:5678 にアクセスして所有者アカウントを作成します。ここで作るアカウントが管理者になります。
出回っている設定のうち、いま動かない 3 つ
私自身、他所の記事からコピーした docker-compose.yml がそのままでは起動せず、原因を切り分けるのに時間を使いました。よく残っているのは次の 3 つです。
| よく見る記述 | いま起きること | 正しい書き方 |
image: docker.n8nio/n8n | docker.n8nio というホストは存在せず、名前解決の時点で pull に失敗します | docker.n8n.io/n8nio/n8n または Docker Hub の n8nio/n8n |
N8N_BASIC_AUTH_ACTIVE=true ほか 2 つ | Basic 認証は 1.0 で削除済み。設定しても無視され、無防備なまま公開されたと勘違いしがちです | 組み込みのユーザー管理を使う。2.17.0 以降なら N8N_INSTANCE_OWNER_MANAGED_BY_ENV で所有者を環境変数から用意できます |
version: '3.8' | Compose V2 では不要な項目として警告が出ます(致命的ではありませんが、古い設定を引き写した目印になります) | 行ごと削除 |
とくに 2 番目は厄介です。エラーが出ずに素通りするため、「Basic 認証をかけたつもり」の状態が続きます。設定したのに認証されている実感がないときは、その環境変数がもう見られていないと考えてよいです。
なお image のタグを :latest にしないのも、後から効いてきます。バージョンを固定していないと、docker compose pull のたびに挙動が変わる可能性があり、フローが止まった原因の切り分けが難しくなります。
VPS への配置と Nginx
私はいま、月額 $6 の VPS(1vCPU / 1GB RAM)に Nginx と Let's Encrypt を載せて運用しています。n8n 単体なら 512MB でも起動しますが、AI ノードに大きめのペイロードを流すなら 1GB は見ておくほうが安全です。
Compose 側でポートを 127.0.0.1:5678 に閉じているので、外から届くのは Nginx 経由だけになります。
# /etc/nginx/sites-available/n8n
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# 添付ファイルを扱うフロー向けに上限を上げる
client_max_body_size 32m;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# エディタの実行結果は SSE で流れてくるのでバッファしない
proxy_buffering off;
proxy_read_timeout 300s;
}
}
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
listen 行のない server ブロックをそのまま貼ってしまうと、443 で受けられずに接続だけが通らない状態になります。X-Forwarded-Proto と先ほどの N8N_PROXY_HOPS は対で必要で、片方が欠けると n8n が生成する Webhook URL が http:// のままになります。
認証情報の暗号鍵は、失うと取り戻せません
セルフホストで一番痛い事故は、サーバーが落ちることではなく、バックアップから戻したのに認証情報が復号できないことでした。
n8n は認証情報を暗号化してデータベースに保存します。その鍵はデータベースの中ではなく、/home/node/.n8n/config か環境変数 N8N_ENCRYPTION_KEY にあります。鍵を指定していない場合、n8n は初回起動時にランダムな鍵を自動生成して config に書き込みます。
つまりボリュームを作り直すと、新しい鍵が生まれます。データベースだけを復元しても、古い鍵で暗号化された認証情報は開けません。docker compose down -v の -v を打ってしまった瞬間に、この状態になります。
対策は 2 つです。鍵を自分で決めて .env から渡すこと。そして、戻せることをバックアップの時点で確かめることです。後者のために、次のスクリプトを毎日のバックアップジョブの最後に噛ませています。
#!/usr/bin/env bash
# backup-verify.sh — 展開済みバックアップが本当に復旧に使えるか調べる
set -uo pipefail
DIR="${1:?usage: backup-verify.sh <dir>}"
fail=0
chk() { if [ -e "$2" ]; then echo " OK $1"; else echo " MISS $1 ($2)"; fail=$((fail+1)); fi; }
echo "[1] 復旧に必要な3点が揃っているか"
chk "データベース" "$DIR/database.sqlite"
chk "設定ファイル" "$DIR/config"
chk "ワークフロー書き出し" "$DIR/export/workflows.json"
echo "[2] 暗号鍵が読めるか"
KEY=$(python3 -c "import json;print(json.load(open('$DIR/config')).get('encryptionKey',''))" 2>/dev/null)
if [ -z "$KEY" ]; then echo " MISS encryptionKey が config にありません"; fail=$((fail+1))
else echo " OK encryptionKey 検出 (fingerprint: $(printf %s "$KEY" | sha256sum | cut -c1-12))"; fi
echo "[3] 前回のバックアップと鍵が同一か"
FP_FILE="$DIR/.key-fingerprint"
NOW=$(printf %s "$KEY" | sha256sum | cut -c1-12)
if [ -f "$FP_FILE" ]; then
PREV=$(cat "$FP_FILE")
if [ "$PREV" = "$NOW" ]; then echo " OK 前回と一致 ($NOW)"
else echo " WARN 鍵が変わりました ($PREV -> $NOW)。古い認証情報は復号できません"; fail=$((fail+1)); fi
else echo " INIT 初回のため fingerprint を記録します"; fi
printf %s "$NOW" > "$FP_FILE"
echo "[4] 認証情報が中身ごと入っているか"
CRED="$DIR/export/credentials.json"
if [ -f "$CRED" ]; then
N=$(python3 -c "import json;print(len(json.load(open('$CRED'))))" 2>/dev/null || echo 0)
D=$(grep -c '"data"' "$CRED" 2>/dev/null || echo 0)
echo " 件数 $N / data フィールドを持つもの $D"
[ "$N" -gt 0 ] && [ "$D" -eq 0 ] && { echo " WARN --decrypted なしの空エクスポートです"; fail=$((fail+1)); }
else echo " MISS credentials.json"; fail=$((fail+1)); fi
echo
[ "$fail" -eq 0 ] && echo "RESULT: 復旧可能" || echo "RESULT: 要対応 $fail 件"
exit $fail
鍵が入れ替わったバックアップを食わせると、こう出ます。
[3] 前回のバックアップと鍵が同一か
WARN 鍵が変わりました (a99e5428f925 -> 783283448b2f)。古い認証情報は復号できません
RESULT: 要対応 1 件
鍵そのものは記録せず、SHA-256 の先頭 12 文字だけを .key-fingerprint に残す形にしています。バックアップ置き場に鍵の平文を置きたくないためです。終了コードを返すので、cron でも || notify の形でそのまま使えます。
意地悪に見えるかもしれませんが、この検証を入れる前と後で安心感がまるで違いました。バックアップは取れているかどうかではなく、戻せるかどうかで判断するものだと考えています。
AI エージェント連携の実装
n8n の AI ノードは 2024 年後半から急速に増え、いまは Claude・Gemini・OpenAI をノードとして組み込み、オーケストレーションまで n8n の中で完結できます。
Claude との接続
Credentials に Anthropic の API キーを登録し、ワークフローに「Anthropic Chat Model」ノードを置きます。プロンプトを動的に組むときは Code ノードを挟みます。
// Code ノード:受信メールから分類用プロンプトを組み立てる
const emailBody = $json.email_body ?? '';
const sender = $json.sender_name ?? '(unknown)';
// 長文メールをそのまま流すとトークンも遅延も膨らむので頭を切る
const excerpt = emailBody.slice(0, 4000);
return {
json: {
prompt: [
'次のメールを分類してください。',
'出力は JSON オブジェクト 1 個だけ。前後に説明文を付けないでください。',
'category は support / sales / other のいずれか、priority は high / medium / low のいずれか。',
'',
`送信者: ${sender}`,
`本文: ${excerpt}`,
'',
'形式: {"category":"...","priority":"...","summary":"..."}',
].join('\n'),
},
};
実用的なフロー1:メール自動分類
これは実際に動かしている構成です。
Gmail トリガー(新着メール)
│
├─ フィルター: 迷惑メール・広告を除外
│
├─ Claude ノード: メール内容を分析・分類
│
├─ Code ノード: 応答から JSON を取り出す(後述の抽出関数)
│
├─ Switch ノード: カテゴリで分岐
│ ├─ サポート → Notion データベースにチケット作成
│ ├─ 営業 → Slack の #sales チャンネルに通知
│ └─ その他 → Gmail ラベル付けのみ
│
└─ 完了通知
実用的なフロー2:週次レポート生成
スケジューラー(毎週月曜 9:00)
│
├─ Google Analytics ノード: 先週のトラフィックを取得
├─ Google Search Console: 検索パフォーマンスを取得
├─ Notion ノード: 先週のタスク完了数を取得
│
├─ Code ノード: 数値を整形してプロンプトに埋め込む
│
├─ Gemini ノード: レポート本文を生成
│
└─ Gmail ノード: 自分宛に送信
長文生成の安定度では Gemini のほうが好みで、分類のように短く構造化された出力を求める場面では Claude を選んでいます。同じフローの中でモデルを混ぜられるのは、セルフホストならではの気楽さです。
実用的なフロー3:SNS 投稿のスケジューリング
Notion トリガー(新規コンテンツ追加)
│
├─ Claude ノード: 各プラットフォーム向けに本文を最適化
│ ├─ X: 280 文字以内
│ ├─ LinkedIn: プロフェッショナルトーン
│ └─ Instagram: ハッシュタグ最適化
│
├─ 承認ステップ(Slack で内容確認 → ボタン承認)
│
└─ 予約投稿ノード: 各プラットフォームへ
自動投稿だけは、承認ステップを外していません。文面を人の目に一度通す 10 秒が、取り返しのつかない投稿を防いでくれます。
「JSON で返して」と書くだけでは足りませんでした
分類フローで最初に安定しなかったのが、この JSON のパースでした。プロンプトで形式を厳密に指定しても、応答の頭に "Sure! Here is the JSON:" が付いたり、コードフェンスで包まれたり、末尾にカンマが残ったりします。そのたびに Switch ノードの手前で落ちます。
そこで、落ち方の型を 13 通りのフィクスチャに並べ、4 つの取り出し方で何件通るかを実際に走らせて比べました。
- S1 素の
JSON.parse
- S2 コードフェンスを剥がしてから
JSON.parse
- S3 最初の
{ から最後の } までを切り出す
- S4 文字列とエスケープを見ながら括弧の対応を走査し、全角引用符・シングルクォート・末尾カンマを整形してから
JSON.parse
結果です。
| 応答の型 | S1 | S2 | S3 | S4 |
| そのまま JSON | OK | OK | OK | OK |
| ```json で囲まれる | NG | OK | OK | OK |
| 言語指定なしのフェンス | NG | OK | OK | OK |
| 前置きの文が付く | NG | NG | OK | OK |
| 前置き+後書きが付く | NG | NG | OK | OK |
| 末尾カンマが残る | NG | NG | NG | OK |
| 全角の引用符になる | NG | NG | NG | OK |
| シングルクォート | NG | NG | NG | OK |
| 値の中に開き波括弧を含む | OK | OK | OK | OK |
| 値の中にエスケープした引用符 | OK | OK | OK | OK |
| 値の中に改行 | OK | OK | OK | OK |
| 後書きに波括弧が混ざる | NG | NG | NG | OK |
| 先頭に BOM | NG | OK | OK | OK |
| 通過数 | 4 / 13 | 7 / 13 | 9 / 13 | 13 / 13 |
素の JSON.parse が 4 件しか通らないのは想像どおりでした。意外だったのは S3 が 9 件で頭打ちになることです。「最初の { から最後の }」は手軽で広く使われている書き方ですが、後書きの説明文に {high|medium|low} のような波括弧が混ざると、そこまで飲み込んで壊れます。私はこの落とし穴に一度はまってから、括弧の対応を自分で走査する形に切り替えました。
実際に使っている抽出関数です。Code ノードにそのまま貼れます。
// Code ノード:LLM 応答から JSON オブジェクトを取り出す
function extractJsonObject(text) {
const t = String(text).replace(/^\uFEFF/, '');
const start = t.indexOf('{');
if (start < 0) throw new Error('オブジェクトが見つかりません');
// 文字列リテラルとエスケープを見ながら対応する } を探す
let depth = 0, inStr = false, esc = false, raw = null;
for (let i = start; i < t.length; i++) {
const c = t[i];
if (esc) { esc = false; continue; }
if (c === '\\') { esc = true; continue; }
if (c === '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c === '{') depth++;
else if (c === '}' && --depth === 0) { raw = t.slice(start, i + 1); break; }
}
if (raw === null) throw new Error('括弧が閉じていません');
const repaired = raw
.replace(/[\u201c\u201d]/g, '"') // 全角ダブルクォート
.replace(/[\u2018\u2019]/g, "'") // 全角シングルクォート
.replace(/'([^'\\]*)'(\s*[:,}])/g, '"$1"$2')
.replace(/([{,]\s*)'([^'\\]*)'(\s*:)/g, '$1"$2"$3')
.replace(/,(\s*[}\]])/g, '$1'); // 末尾カンマ
return JSON.parse(repaired);
}
const ALLOWED_CATEGORY = ['support', 'sales', 'other'];
const ALLOWED_PRIORITY = ['high', 'medium', 'low'];
return $input.all().map((item) => {
let parsed;
try {
parsed = extractJsonObject(item.json.text ?? item.json.response ?? '');
} catch (e) {
// 落とさずに other へ寄せる。原因調査用に生応答を残す
return { json: { category: 'other', priority: 'low', summary: '', parse_error: e.message, raw: item.json.text } };
}
return {
json: {
category: ALLOWED_CATEGORY.includes(parsed.category) ? parsed.category : 'other',
priority: ALLOWED_PRIORITY.includes(parsed.priority) ? parsed.priority : 'low',
summary: String(parsed.summary ?? '').slice(0, 500),
},
};
});
ここでもう一つ大事なのは、パースに失敗しても例外を投げずに other へ寄せている点です。n8n の Code ノードで例外を投げると、その実行全体が失敗扱いになります。1 通のおかしなメールでフロー全体が止まると、後続のメールも溜まっていきます。壊れた 1 件を分類の外に逃がして、parse_error を添えて記録に残すほうが、運用は静かになりました。
ALLOWED_CATEGORY で値を絞っているのも同じ考えです。モデルが "Support" や "サポート" を返してきても、Switch ノードは黙って全部の分岐を外れます。想定外は想定外として一箇所に集めておくと、後から気付けます。
AI エージェントノードの使いどころ
最近の n8n には「AI Agent」ノードがあり、単発の LLM 呼び出しを超えて、ツールを選びながら動くエージェントを組めます。
AI エージェントノード
├─ System Prompt: 「あなたはデータ分析エージェントです。ツールを使って...」
├─ ツール: Calculator / HTTP Request / Code Executor
└─ モデル: Anthropic Chat Model(Credentials で指定)
エージェントが自分で「ここは計算が要る」「データが足りないから取りに行く」と判断して動きます。私はこれを、記事の下調べを集めるフローに使っています。
一方で、決まった手順を回すだけの処理にエージェントノードを使うと、実行時間も料金も素直に増えます。分岐が読み切れているなら Switch と IF で組むほうが速く、再現性もあります。エージェントは、手順を事前に書き切れない場面のための道具という線引きで使い分けています。
セルフホストで気を付けている 3 点
1. Webhook のパスを推測されにくくする
既定の /webhook/ をそのまま公開していると、スキャナーに叩かれます。/webhook/a7f3k9m2b6/ のようにランダムな文字列を含めるだけでも、ログの雑音がかなり減ります。
2. API キーはワークフローに直接書かない
Credentials 機能か .env に置きます。ワークフローの JSON をエクスポートして共有する場面が必ず来るので、そのときに漏れる形にしておかないことが大事です。
3. 実行ログを溜め込まない
既定のままだと実行データが増え続け、SQLite が重くなります。EXECUTIONS_DATA_PRUNE と EXECUTIONS_DATA_MAX_AGE で保持期間を切っておくと、小さな VPS でも落ち着いて動きます。
個人開発者の視点から(実体験メモ)
フローは 3 本までにして、増やす前に統合する
最初の頃、思い付くたびにフローを足していったら、どれが何を担当しているのか自分でも追えなくなりました。いまは「動いているフローが 3 本を超えたら、新しく作る前にまとめられないか考える」というだけの決まりを置いています。似た処理を 1 本に寄せて Switch で分けるほうが、変更のときに見る場所が減ります。
AI ノードは、まず AI なしで組んでから差し込む
新しいフローを組むとき、いきなり LLM を挟むと、出力が揺れているのかフローの配線が間違っているのか切り分けられません。固定値を返す Code ノードで一度端から端まで通してから、その位置に AI ノードを置き換える。この順番にしてから、原因の見当が付くまでの時間が明らかに短くなりました。
止まったフローに気付く仕組みだけは、外に置く
n8n のエラーワークフローは便利ですが、n8n 自体が落ちていると通知も飛びません。私はコンテナのヘルスチェックと、一日一回 Webhook を外から叩いて応答を見る仕掛けを、別のところに置いています。監視する側と監視される側を同じ箱に入れないという、当たり前といえば当たり前の話です。
次のステップ
まずは docker-compose.yml の 3 点、つまり image 名・BASIC_AUTH 系の残骸・N8N_ENCRYPTION_KEY の明示だけ確かめてみてください。ここが揃っていれば、少なくとも「戻せないバックアップ」は避けられます。
その次は、AI ノードの後ろに置いている JSON のパースです。素の JSON.parse のままなら、上の抽出関数に差し替えるだけで、静かに失敗していた実行が拾えるようになるはずです。
自動化は作って終わりではなく、育てるものだと思っています。私自身まだ手探りの部分が多いのですが、詰まりどころが誰かの時間の節約になれば嬉しいです。お読みいただきありがとうございました。
設定例は n8n 2.35.5 で確認しています。1.x 系を使い続ける場合も、暗号鍵と image 名に関する内容はそのまま当てはまります。