プライベート環境内のn8nでSlackイベントを受ける

こんにちは、エキサイトでエンジニアをしております。吉川です。 エキサイトHDアドベントカレンダー2025の7日目の記事になります。

qiita.com

この記事の前提とゴール

この記事では 「プライベート環境内にセルフホストしたn8n」 で、Slackのイベント(Events API)をトリガーにワークフローを起動するための構成を紹介します。

  • 前提
    • AWSVPC / EC2 / API Gateway / Lambda)の基本用語が分かる
    • Docker / docker compose が使える
    • Slackアプリを作成できる権限がある
  • ゴール
    • Slack →(インターネット)→ API Gateway → Lambda →(VPC内)→ n8n という経路で、Slackイベントを安全にn8nへ届ける

n8nとは

n8nは、ノーコード/ローコードで様々なサービス間の連携を実現できるワークフロー自動化ツールです。トリガーとアクションを組み合わせることで、複雑な業務プロセスを自動化できます。

n8nには大きく分けて2つの利用方法があります:

  • n8n Cloud: n8nが提供するクラウドサービス。インフラの管理が不要で、すぐに利用開始できます
  • セルフホスト: 自社のサーバーやクラウド環境に自分でn8nをデプロイする方法。カスタマイズ性が高く、データを自社環境内に保持できます

今回は、セキュリティ要件の観点からセルフホスト版のn8nをプライベートサブネット内に構築し、そこでSlackトリガーを利用する方法について解説します。

セルフホスト版のn8nをプライベートサブネット内に立てる

EC2インスタンスの準備

まず、AWSのプライベートサブネット内のEC2インスタンスを立ち上げ、インスタンス内で以下のようにcompose.yamlファイルを作成します。

services:
  n8n:
    image: n8nio/n8n:latest
    container_name: n8n
    ports:
      - "5678:5678"
    environment:
      - GENERIC_TIMEZONE=Asia/Tokyo
      # Slackに登録するRequest URL(=外部公開URL)のベース。
      - WEBHOOK_URL=${PUBLIC_WEBHOOK_BASE_URL}
    volumes:
      - n8n_data:/home/node/.n8n
volumes:
  n8n_data:

compose.yamlを作成したら、以下のコマンドでn8nを起動します。

# DockerとDockerComposeはインストールされている前提
# n8nコンテナの起動
docker compose up -d

PUBLIC_WEBHOOK_BASE_URLcompose.yaml と同じ階層の .env などで設定します(Slackに登録するRequest URLのベースなので https のURLになります)。

セキュリティグループの設定

プライベートサブネット内のn8nは、外部からの不正アクセスを防ぐために、セキュリティグループで厳格なアクセス制御を設定します。

  • インバウンドルール:
    • ポート5678(n8nのUIアクセス):社内の踏み台/VPN/SSM経由など、運用方針に沿った経路のみに限定
    • ポート5678(Lambda → n8n 転送用):後述のLambda(またはLambdaに紐づくセキュリティグループ)からのみ許可

この設定により、社内からのみn8nの管理画面にアクセスでき、外部からの直接アクセスは遮断されます。

セルフホスト版n8nとSlackトリガー

Slackトリガーの基本設定

n8nでSlackトリガーノードを追加すると、SlackのEvent Subscriptionsに登録するためのRequest URL(Webhookエンドポイント)が提示されます。

このURLをSlackアプリのEvent Subscriptionsに設定することで、Slackでのイベント(メッセージ投稿、リアクション追加など)をトリガーにワークフローを実行できます。

問題:SlackのIPアドレスが固定されていない

ここで問題が発生します。Slackはグローバルな分散インフラストラクチャで運用されており、Webhookリクエストの送信元IPアドレスが固定されていません。そのため、セキュリティグループで特定のIPアドレスのみを許可する設定では、Slackからのリクエストを受け付けることができません。

かといって、すべてのIPアドレスからのアクセスを許可してしまうと、セキュリティリスクが高まり、プライベート環境に配置した意味がなくなってしまいます。

Lambdaを経由してn8nに送る

この問題を解決するために、AWS API Gateway + Lambdaを中継ポイントとして利用します。

この構成のポイントは以下です。

  • Slackは インターネット上のHTTPSエンドポイント(= API Gateway)にしか投げられない
  • n8nは プライベート環境内に閉じたまま、Lambdaからのみ到達可能にする
  • Slackリクエストは IP制限ではなく署名検証(Signing Secret) で正当性を担保する

注意点として、SlackのEvents APIには「最初のURL検証(url_verification)」や「応答は概ね3秒以内」などの要件があるため、Lambda側でそれを満たす実装にします。

API Gatewayの設定

API GatewayREST APIを作成し、エンドポイントを公開します。このURLをSlackアプリのEvent SubscriptionsのRequest URLに設定します。

Lambda関数の実装

Lambda関数では以下の処理を実装します。

プライベートサブネット内にリクエストを送信するため、LambdaのVPC設定で上記のEC2と同じVPC・サブネットを設定します。またEC2のセキュリティグループで、Lambdaのセキュリティグループからのアクセスを許可します。

また環境変数(必要ならSecrets Manager等)にSLACK_SIGNING_SECRETN8N_TRIGGER_URL を設定します。N8N_TRIGGER_URL はn8nが表示するWebhook URL(上記のPUBLIC_WEBHOOK_BASE_URL)の パスはそのままに、ベースだけを http://<EC2のIP>:5678 にしたものを使います。

Lambdaのコードは以下のようになります。

import os
import json
import hmac
import hashlib
import base64
from datetime import datetime
import requests

# Slackからのリクエスト検証 
def validate_request_from_slack(slack_signing_secret: str, headers: dict, body: str) -> bool:
    timestamp = headers.get("X-Slack-Request-Timestamp")
    slack_signature = headers.get("X-Slack-Signature")

    # 直近のリクエストか検証
    current_time = int(datetime.now().timestamp())
    if abs(current_time - int(timestamp)) > 60 * 5:
        return False

    # 署名検証
    sig_basestring = f"v0:{timestamp}:{body}"
    signature = (
        "v0="
        + hmac.new(
            slack_signing_secret.encode("utf-8"),
            sig_basestring.encode("utf-8"),
            hashlib.sha256,
        ).hexdigest()
    )
    return hmac.compare_digest(signature, slack_signature)

# n8nへのリクエスト転送
def send_n8n(n8n_trigger_url: str, body: dict) -> None:
    requests.post(
        url=n8n_trigger_url,
        headers={"Content-Type": "application/json"},
        json=body,
    )

def lambda_handler(event, context):
    headers = event.get("headers") or {}
    body = event.get("body") or ""
    if event.get("isBase64Encoded"):
        body = base64.b64decode(body).decode("utf-8")

    slack_signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "")
    if not slack_signing_secret or not validate_request_from_slack(slack_signing_secret, headers, body):
        return {"statusCode": 401, "body": "invalid signature"}

    # SlackのURL検証(Event Subscriptionsを有効化する際に必須)
    try:
        payload = json.loads(body) if body else {}
    except json.JSONDecodeError:
        payload = {}
    if payload.get("type") == "url_verification":
        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"challenge": payload.get("challenge", "")}),
        }

    n8n_trigger_url = os.environ.get("N8N_TRIGGER_URL", "")
    if not n8n_trigger_url:
        return {"statusCode": 500, "body": "missing N8N_TRIGGER_URL"}

    send_n8n(n8n_slack_trigger_url, payload)

    return {"statusCode": 200, "body": "ok"}

ポイント:

  • SlackのEvent Subscriptionsは最初に url_verification を投げてくるため、challenge応答がないと有効化できない
  • 署名検証は 「受け取った生のbody」 に対して行う(JSONとしてパースして並び替えると署名が一致しなくなる)

まとめ

セルフホスト版のn8nは、それ自体は無料(EC2料金は別途かかる)で使用でき、データも自社環境内に保持できるため、機密情報を扱う業務フローでも安心して利用できます。

また、非エンジニアの方でもn8nのGUIを使って安全に業務自動化を進められるようになり、組織全体の業務効率化に貢献できると考えています。

セキュリティ要件が厳しい環境でのワークフロー自動化を検討されている方の参考になれば幸いです。