「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のみ管理」です。これなら事故りようがありません。
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活用記事もあわせて参考にしてみてください。
IT・SaaS専門の比較メディア。中小企業の導入担当者向けに独自調査・中立的な比較情報を提供