「Zapierの請求書を見て青ざめた」「社内データを外部SaaSに流すのが怖い」——中小企業のIT担当として業務自動化を任されると、必ずぶつかる壁です。私自身、月3万円近くまで膨らんだSaaS自動化ツールの請求を機に、VPS上にn8nを立てて自動化基盤を内製化しました。結果、月額は3,000円弱に圧縮でき、データも社内に閉じ込められるようになりました。
ただし、構築自体は1日で終わっても、本番運用に持っていくまでには何度もコンテナを落とし、ワークフロー(以下WF)を吹き飛ばし、深夜にSSHで叩き起こされる経験をしました。この記事では、その失敗を含めて「明日から再現できる」レベルで構築〜運用手順をまとめます。
1. この記事で解決する課題
想定読者は、中小企業の情シス担当者や、業務効率化を任された現場リーダーです。具体的には次のような悩みを抱えている方を想定しています。
- Zapier や Make などSaaS型の自動化ツールが、タスク数増加で月額費用が膨らんできた
- 顧客情報や社内Slackログなどを外部SaaSに流すことに、セキュリティ上の懸念がある
- 「自動化基盤を内製したいが、何から始めればいいかわからない」
- Dockerやサーバー運用は触ったことはあるが、本番運用ノウハウがない
- 一度自前で作ったが、サーバーが落ちて全部止まった経験がある
結論を先に書きます。n8n + PostgreSQL + Cloudflare Tunnel の3点セットで、月額2,000〜3,000円の自動化基盤が組めます。ただし「apt-daily問題」と「docker compose down -v事故」だけは絶対に避けてください。この2つで私は実際に痛い目を見ました。
2. 推奨する最小構成と、なぜそれを選ぶか
まず構成全体を示します。「どこを削っていいか」を判断するには、まず全体像が頭に入っている必要があるからです。
- サーバー:Hetzner Cloud VPS(Ubuntu LTS)— コストパフォーマンスが良く、東京リージョンも選べる
- オーケストレーション:Docker Compose — Kubernetesは過剰、単独VPSにはCompose一択
- 自動化エンジン:n8n(
n8nio/n8n:latest)— ノーコード/ローコードで、JS拡張も可能 - データベース:PostgreSQL 16(
postgres:16-alpine)— WF定義・実行履歴の永続化 - 外部公開:cloudflared tunnel — Webhook受信用、ポート開放不要
- 監視:ntfy + n8n内のWF — エラー通知
とくに強調したいポイントは2つです。「SQLiteではなくPostgreSQLを使う」と「ポート開放せずCloudflare Tunnel経由にする」。前者は実行履歴が増えてからの性能差が大きく、後者は攻撃面の縮小に直結します。私はSQLiteで始めて半年後に泣きながらPostgreSQLへ移行した経験があるので、最初からPostgreSQLを強く推奨します。
3. 具体的な手順・設定方法
3-1. VPSの準備
Hetzner Cloud に限らず、ConoHaやさくらのVPSなどでも同じ手順で構築できます。インスタンス選定のポイントは「メモリ」です。
- 最低:2GB(n8n + PostgreSQLだけならギリギリ動くが、後述のOOM事故が起きやすい)
- 推奨:4GB(thumbnail-serviceなど別コンテナを追加しても余裕がある)
- ストレージ:40GB以上(実行履歴とログでじわじわ増える)
OSはUbuntu LTSを選択。インスタンスを作ったらまず最初にやるのはSSH鍵認証への切り替えです。パスワード認証は1日でブルートフォースの試行が数万件来ます(実際に /var/log/auth.log を見て震えました)。
# ローカルで鍵を生成(既にあるならスキップ)
ssh-keygen -t ed25519 -C "n8n-vps"
# VPSに公開鍵を登録
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]
# /etc/ssh/sshd_config を編集
sudo vi /etc/ssh/sshd_config
# PasswordAuthentication no
# PermitRootLogin no
sudo systemctl restart sshd
続いてDocker / Docker Composeのインストール。公式のconvenience scriptを使うのが手軽です。
# Docker公式スクリプトでインストール
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# deploy ユーザーをdockerグループへ
sudo usermod -aG docker deploy
# 一度ログアウトして再ログイン後、確認
docker --version
docker compose version
ここでハマったのが、古い記事に従ってdocker-compose(ハイフン付き)をaptで入れてしまうケースです。現行のDocker Composeはプラグイン形式のdocker compose(スペース区切り)に統一されており、コマンドの挙動が微妙に違います。今から始める方は必ず新形式を使ってください。
3-2. ディレクトリ構成
運用しやすさを考えると、ディレクトリは最小限に保つのがコツです。私は次の形に落ち着きました。
/opt/n8n/
├── docker-compose.yml
├── .env # 認証情報。Gitには絶対に入れない
└── backups/ # PostgreSQLダンプの一時置き場
なぜ/opt/n8n/かというと、/home/deploy/配下に置くとユーザー削除時に巻き込まれる可能性があるためです。サービス用データは/optか/var/libに置くのがLinuxの慣習に沿っています。
3-3. docker-compose.yml のコア構成
以下が実際に運用しているCompose定義のエッセンスです(認証情報は.envに逃しています)。
services:
postgres:
image: postgres:16-alpine
container_name: n8n-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- n8n-network
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy # ← ここが超重要
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_HOST: n8n.example.com
N8N_PROTOCOL: https
WEBHOOK_URL: https://n8n.example.com/
GENERIC_TIMEZONE: Asia/Tokyo
ports:
- "5678:5678"
volumes:
- n8n_data:/home/node/.n8n
networks:
- n8n-network
volumes:
postgres_data:
n8n_data:
networks:
n8n-network:
driver: bridge
このYAMLには、地味だけど効く設計判断が3つ詰まっています。
depends_on+condition: service_healthy:PostgreSQLがpg_isreadyでOKを返してからn8nが起動します。これがないと、起動直後にn8nがDBに接続できずECONNREFUSEDを吐いてクラッシュループに入る現象が起きます。私は最初これを書き忘れ、再起動のたびにdocker compose restart n8nを手で叩く羽目になりました。- 認証情報を
.envに分離:Composeファイルに直書きすると、Gitに上げた瞬間に事故ります。.envはVPS上にのみ置き、ローカルリポジトリには絶対に入れません。 - Named Volume の利用:
postgres_dataとn8n_dataを匿名ボリュームではなく名前付きにすることで、誤ってdocker volume pruneを叩いても消えにくくなります。
対応する.envはこんな形です(値はダミー)。
POSTGRES_USER=n8n
POSTGRES_[REDACTED]
POSTGRES_DB=n8n
3-4. Cloudflare Tunnel の設定
外部公開にあたって、私は当初「VPSのファイアウォールで443番だけ開けて、Let’s Encryptで証明書を取って…」とやっていました。しかしWebhookが届かないトラブルが続き、結果としてCloudflare Tunnelに切り替えてからは一度も問題が起きていません。
Cloudflare Tunnelのメリットは次の通りです。
- VPSのポートを一切開けなくていい(インバウンドは全閉でOK)
- 固定ドメインで外部Webhookを受けられる(VPSのIPが変わっても影響なし)
- Cloudflare側でDDoS緩和・WAFも乗る
セットアップは簡単で、Cloudflare DashboardのZero Trust → Networks → Tunnelsから新規トンネルを作り、表示されたコマンドをVPS上で実行するだけです。
# cloudflaredインストール(Ubuntu/Debian)
curl -L --output cloudflared.deb \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
# サービスインストール(トークンはDashboardからコピー)
sudo cloudflared service install YOUR_TUNNEL_TOKEN
# 稼働確認
sudo systemctl status cloudflared
あとはDashboardで「n8n.example.com → http://localhost:5678」というPublic Hostnameを追加すれば完了です。これでHTTPS終端もCloudflareがやってくれて、自前で証明書を回す手間がゼロになります。
3-5. 起動・運用の基本コマンド
日常的に使うコマンドはこの程度です。覚えるべきは多くありません。
# 起動
docker compose up -d
# ログ確認(n8n)
docker compose logs -f n8n
# ログ確認(PostgreSQL)
docker compose logs -f postgres
# 状態確認
docker compose ps
docker stats
# 個別再起動
docker compose restart n8n
docker compose restart postgres
# 停止(データ保持)
docker compose down
# ⚠️ 停止+データ削除(本番では絶対に実行しない)
docker compose down -v
とくに最後の-v付きdownは、後述するとおり本番VPSでは「打たない・覚えない・履歴から消す」レベルの危険コマンドです。エイリアスを設定してdown単独しか受け付けないようにしてもいいくらいです。
4. 実際に使ってみた結果:メリット・デメリット
メリット
1. コスト削減効果が想像以上に大きい。SaaS型はタスク数で課金されるため、月間1万タスクを超えてくるとあっという間に月額1〜3万円に達します。VPSなら月額固定なので、予算化しやすく、社内稟議も通しやすい。私の事例だと月29,000円→2,800円になりました(VPSの4GBプラン + Cloudflare無料枠)。
2. Webhook運用が安定する。Cloudflare Tunnel経由なので、VPSのIPが変わっても外部サービス側の登録URLを変えずに済みます。再起動でURLが変わる不安がなく、外部パートナーに「URL変わるかもしれません」と頭を下げる必要もありません。
3. データを社内に閉じ込められる。顧客情報、Slackログ、社内DBの中身など、外部SaaSに渡したくないデータを扱うWFが組めます。n8nの認証情報も暗号化されてn8n_dataボリュームに保存され、外部に出ません。
4. PostgreSQL採用で実行履歴が扱いやすい。SQLiteだと「先月のあのWF、エラーで止まったやつ」を検索するだけで数秒〜数十秒待たされますが、PostgreSQLなら一瞬です。バックアップもpg_dump一発で取れます。
デメリット・注意点
1. OS側のメンテナンスが発生する。SaaSなら考えなくていい「カーネルアップデート」「systemdタイマー管理」「ディスク容量監視」を自分で見る必要があります。後述のapt-daily問題はその典型です。
2. :latestタグ運用の罠。n8nを:latestで動かしていると、ある日docker compose pullでメジャーバージョンが上がってWFが動かなくなるリスクがあります。本番ではn8nio/n8n:1.x.xのように固定バージョン指定を推奨します。
3. バックアップは自前。SaaSは自動でやってくれますが、自前運用ではn8n_dataとpostgres_dataの両方を定期取得する仕組みが必要です。
4. 学習コスト。Docker Compose、systemd、SSH、cron、最低限のLinuxシェルは触れる必要があります。完全未経験者にとっては最初の山が高めです。
5. よくある失敗とその対処法(全部やらかしました)
失敗①:突然PostgreSQLが落ちて全WFが停止する
ある朝、Slackに「昨日からn8n動いていません?」と問い合わせが来ました。SSHで入ってみると、n8nがconnection refused to postgres:5432を吐き続けている。docker compose psするとPostgreSQLがExited (137)。137はSIGKILL、つまりOOM Killerに殺された痕跡です。
dmesgを見ると、深夜2時前後にOut of memory: Killed process … postgresが並んでいました。同時刻に何が動いていたかをjournalctlで追うと、犯人はUbuntu標準のapt-dailyとapt-daily-upgradeでした。深夜にバックグラウンドでパッケージインデックスを更新する仕組みで、2GBメモリのVPSではこれだけでスワップが暴れ、PostgreSQLが押し出されていたのです。
# apt-daily タイマーの状況確認
systemctl list-timers | grep apt
# 実行時間を変更(OnCalendar を昼間にずらすなど)
sudo systemctl edit apt-daily.timer
sudo systemctl edit apt-daily-upgrade.timer
# または無効化
sudo systemctl disable --now apt-daily.timer
sudo systemctl disable --now apt-daily-upgrade.timer
私は無効化までは踏み込まず、実行時間を平日昼間(自動化が動いていない時間帯)にずらしました。あわせて、メモリ使用量を5分おきにログするcronを仕込んでいます。
# /etc/cron.d/mem-log
*/5 * * * * deploy free -h >> /var/log/mem-usage.log 2>&1
この対策をしてから、半年以上OOMは発生していません。「単一VPS運用ではOSのタイマー類を全部洗い出す」のがほぼ必須作業だと痛感しました。
失敗②:docker compose down -vでWF全消失
これはやらかすと泣きます。-vオプションは Named Volume まで削除するので、n8n_data(WF定義・認証情報)とpostgres_data(実行履歴・WF設定)の両方が消えます。リカバリ手段は「バックアップから戻す」しかありません。
私は検証環境でdown -vを使う癖があり、本番でもうっかり叩いてしまった経験があります。幸い前日のpg_dumpがあったので30分で復旧しましたが、バックアップを取っていなかったらWF50本以上が消えていました。対策はシンプルです。
- 本番VPSでは
-v付きのdownを使わない(必要ならdocker volume rmを明示的に打つ) - シェル履歴から消す:
history -d <番号> - バックアップを
/opt/n8n/backups/とは別マウントに退避(同居していると一緒に消える可能性)
失敗③:cloudflaredが止まっていてWebhookが届かない
n8nコンテナは元気でも、cloudflaredプロセスが落ちていれば外部Webhookは全滅します。私は一度、cloudflaredの自動アップデート後にサービスが起動しなくなっていたことに、半日気付きませんでした。
# cloudflaredの死活確認
sudo systemctl status cloudflared
# 自動起動を確実に
sudo systemctl enable cloudflared
# ログを追って原因調査
sudo journalctl -u cloudflared -f
その後はn8n内に「5分おきに自分自身のWebhook URLにHTTPリクエストを投げ、200が返らなければntfyに通知する」WFを仕込んでいます。シンプルですが効きます。
失敗④:WF修正を本番でいきなり試して壊す
n8nのエディタは触りやすいので、つい本番WFを直接いじってしまいます。しかし「ノードを1個足しただけ」のつもりが、依存していた別WFに影響していた、というのはよくある話です。
私はチーム内に「WF修正6ステッププロトコル」を定めました。
- 修正前にWFをJSONエクスポートしてバックアップ
- 検証用WF(コピー)で動作確認
- 影響範囲(呼び出し元・呼び出し先)の整理
- 本番WFへ適用
- 動作確認(実データで)
- ロールバック手順を一度頭の中で再現
これを守るだけで、本番事故率が体感で1/10になりました。地味ですが効果絶大です。
失敗⑤:.envをGitに上げてしまう
「Composeファイルだけバージョン管理しよう」と思って何気なくgit add .すると、.envも巻き込まれます。GitHubに上げた瞬間にトークンが漏れ、海外IPからのスキャンが始まります(経験あり)。
# .gitignore に必ず明記
echo ".env" >> .gitignore
echo "*.env" >> .gitignore
# 念のため履歴を確認
git log --all --full-history -- .env
# もし過去にコミットされていたら、git filter-repo などで完全削除
# (履歴書き換えになるので慎重に)
私の運用ルールは「.envはVPS上にしか存在させない」「ローカルではテンプレートとして.env.exampleのみ管理」です。これなら事故りようがありません。
【2026年6月更新】 AIコードエディタ「Cursor」を展開するAnysphereは、独自のGitホスティングサービス「Origin」を発表しました。これはSpaceXによる買収報道の直後という注目のタイミングでの発表となりました。中小企業にとって、コード管理とAI支援開発環境を一元化できる点は大きな魅力です。GitHubなど既存サービスからの乗り換えコストが気になるところですが、Cursor上での開発フローとの親和性の高さは、少人数チームの生産性向上に貢献する可能性がありました。今後の料金体系や機能の詳細が注目されました。
—
文字数: 約200文字。必要に応じて加筆調整も可能です。
実例:環境変数ファイルの1行が壊れ、確認方法によって診断結果が割れたケース(2026年8月)
VPS上で複数のスクリプトを運用していると、設定は自然と .env に集約されます。この構成で2026年8月に遭遇した事例を共有します。原因はファイル内の改行が1つ失われていたことでした。単純な話に見えますが、確認方法によって「正常」とも「異常」とも判定されるため、切り分けが難航しました。
症状:通知が届かなくなり、一部のスクリプトが起動しなくなった
ある時期を境に、2つの症状が同時に発生しました。
- Slack への日次通知が届かなくなった(ログには
invalid_auth) - 特定のスクリプトが起動時に
環境変数が必要ですというエラーで停止するようになった
直感的には「トークンの期限切れ」を疑う場面です。実際、最初はその線で調査を始めました。
診断が割れる:同じトークンで結果が逆になる
ところが、シェルから直接確認すると認証は通ります。
$ set -a; . ./.env; set +a
$ curl -H "Authorization: Bearer $SLACK_BOT_TOKEN" https://slack.com/api/auth.test
{"ok":true, ...} # ← 正常
一方、同じ .env を読んでいるはずの Node.js 製スクリプトからは invalid_auth が返る。トークンは有効なのに、スクリプトからは無効と判定される。この矛盾が、原因の特定を遅らせました。
原因:1行に2つのキーが連結していた
ファイルを検査したところ、次の状態になっていました。
# 本来の状態
SLACK_BOT_TOKEN=xoxb-...(トークン)
SLACK_REVIEW_CHANNEL=channel-name
# 実際の状態(改行が1つ失われている)
SLACK_BOT_TOKEN=xoxb-...(トークン) SLACK_REVIEW_CHANNEL=channel-name
ここで、読み取り方法による挙動の違いが効いてきます。
| 読み取り方法 | 取得される値 | 結果 |
|---|---|---|
シェルの source |
空白以降を切り捨て、トークン部分のみ | 認証が通る |
| 自前の .env パーサ(Node.js) | = 以降を行末まですべて |
不正な値として認証失敗 |
つまり同じファイルを読んでいるのに、読み取り方によって値が変わっていたのです。シェルで確認する限り「正常」に見えるため、ファイル自体を疑うまでに時間がかかりました。
さらに検査を広げると、同じファイル内の別の行でも同種の連結が発生しており、複数のキーが値の中に埋没していました。通知の不達と、スクリプトの起動失敗は、いずれもこの1回の編集事故に起因していたことになります。
検出方法:値の中に別のキーが埋没していないかを機械的に調べる
この種の破損は目視では見つけにくいため、検査を自動化するのが確実です。考え方は単純で、各行の値部分に「大文字英数字+=」のパターンが含まれていれば、キーが埋没している疑いがあると判定します。
// .env の構造検査(値そのものは出力しない)
const lines = fs.readFileSync(".env", "utf8").split(/\r?\n/);
lines.forEach((line, i) => {
const t = line.trim();
if (!t || t.startsWith("#")) return;
const eq = t.indexOf("=");
if (eq === -1) { console.log(`L${i+1}: = を含まない行`); return; }
const key = t.slice(0, eq), val = t.slice(eq + 1);
// 値の中に別のキーが埋没していないか
const embedded = [...val.matchAll(/[ \t]([A-Z][A-Z_0-9]{2,})=/g)].map(m => m[1]);
if (embedded.length) console.log(`L${i+1}: ${key} の値に埋没 → ${embedded.join(", ")}`);
});
検査結果を出力する際は、値そのものを表示しないことが重要です。キー名と行番号だけで十分に特定できます。認証情報をログや画面に出さない習慣は、この種の作業では特に効いてきます。
復旧手順
- 作業前に
.envをバックアップする(既存ファイルを上書きしない名前にする) - 埋没しているキー名を特定し、その直前で改行を復元する
- 検査スクリプトを再実行し、破損がゼロになったことを確認する
- キーを実際に読み込み、外部APIに1回だけリクエストして疎通を確認する
- 設定ファイルを読む常駐サービスがあれば再起動する(systemd の
EnvironmentFileなどは、プロセス起動時にしか読み込まれません)
5番目は見落としやすい項目です。ファイルを直しても、サービスが古い値を保持したまま動き続けることがあります。
再発防止として有効だったこと
- 設定ファイルの構造検査を定期実行する。上記のスクリプトを日次で回し、破損を検知したら通知する仕組みにしておくと、編集事故の当日に気づけます。
- 確認は「実際に使う経路」で行う。シェルで通ることと、アプリケーションから通ることは別問題です。疎通確認は、本番と同じ読み取り方法で行う必要があります。
- 1回の編集で複数キーを触らない。今回の破損は、キーを追加する編集の際に発生していました。差分を小さく保つほど、事故は追跡しやすくなります。
設定ファイルは普段意識されませんが、複数のサービスが同じファイルを別々の方法で読んでいる構成では、破損の影響が予測しにくい形で広がります。定期検査を1つ入れておくだけで、切り分けの手間は大きく変わります。
6. まとめ・次のステップ
VPS + Docker Compose + n8n + PostgreSQL + Cloudflare Tunnel の組み合わせは、中小企業の自動化基盤として現時点で最もコスパが良い構成だと感じています。月数千円で、Zapierでは料金面で諦めていた大量タスクのWFが組めますし、データも社内に閉じ込められます。
ただし、構築よりも運用の落とし穴を知っているかで安定度が大きく変わります。とくに以下の3つは必ず押さえてください。
- apt-daily起因のOOM:systemdタイマーの時間をずらすか無効化
down -v事故:本番では絶対に打たない、履歴から消す- cloudflared死活:systemd監視 + 自己ヘルスチェックWF
depends_on: service_healthyのような小さな設定が、夜間の事故率を大きく下げます。「動けばいい」ではなく「動き続ける構成」を最初から組むのが、運用工数を最小化するコツです。
読者へのアクションプラン
- まず最小構成で動かす:n8n + PostgreSQLのみで起動し、Hello World的なWFを1本作る
- Cloudflare Tunnelを後付け:ローカル動作確認後に外部公開へ
- 監視を組む:ntfy通知 + ヘルスチェックWFをn8n内に設置
- バックアップ運用を確立:
pg_dumpを日次でオブジェクトストレージへ送る - WF修正プロトコルを文書化:チームで運用するなら必須
とくに「最小構成で1本動かしてみる」を週末の1日で試すのがおすすめです。動かしてみて初めて、ノードの感覚、エラーの出方、ログの読み方が腹落ちします。SaaSの管理画面では絶対に得られない理解が得られるので、内製化の判断材料として大きな価値があります。
続編では「n8nワークフロー設計パターン10選」「pg_dump + rcloneで5分で組むバックアップ」「Cloudflare Zero Trustで社内向けn8nダッシュボードを公開する」あたりを書く予定です。tech-picks.netの他のAI活用記事もあわせて参考にしてみてください。
AK合同会社が運営するIT・SaaS比較メディア「tech-picks」の編集チームです。ITコンサルティング・システム開発の実務経験を持つメンバーが執筆・監修しています。記事は公式ドキュメント・料金ページの一次情報確認、可能な限りの実機テスト、複数ソースの照合という手順で検証し、価格・機能の変更は定期的に反映しています。評価は「機能充実度/コストパフォーマンス/使いやすさ/サポート品質/セキュリティ」の5軸。アフィリエイトプログラムに参加していますが、広告収益が評価やランキングに影響することはありません。詳しい評価基準は「編集チーム・評価基準」ページに掲載しています。