🔧

機密情報をディスクに一切残さないでn8nをAWSでセルフホストする(n8nハンズオン 1/3)

に公開

はじめに

ワークフロー自動化ツールであるn8nをAWS上にセルフホストするにあたって、「個人運用だが本番品質を妥協したくない」という要件で設計をした記録です。具体的には次のような条件を満たします。

  • DNSは既存のCloudflareを使う
  • メールサーバーは既存のDNSで使い続ける(既存のMXレコードに干渉しない)
  • 機密情報をEC2インスタンスのディスクには平文で置かない
  • ルートユーザーでは通常運用しない(IAMユーザーで分離)
  • 月額コストはおおむね$20以内に収める

完成すると、https://n8n.example.comのような独自ドメインでn8nが稼働し、再起動しても機密情報はSSM Parameter Storeから自動で復元されます。

対象読者は、AWSの基礎概念(VPC、EC2、IAM)に触れたことがあり、SSHとDockerの基本操作ができる方です。逆に、本記事は「n8nクラウド版で十分な人」には向きません。コスト計算でクラウド版に軍配が上がるケースは多いので、最後に判断基準も書いています。

完成形のアーキテクチャ

AWS構成図

設計判断の前提

実装に入る前に、選択肢を絞り込んだ理由を整理しておきます。別の人が同じ要件で構築する際に判断の根拠を辿れるようにするためです。

なぜLightsailではなくEC2か

Lightsailは固定月額で扱いやすく、個人用VPSとしては優れていますが、IAM Instance ProfileをアタッチするAPIが公開されていません。つまり「機密情報をディスクに置かず、IAM Role経由でSSMから取得する」という構成がLightsailではそもそも実現できません。

回避策として「専用IAMユーザーのアクセスキーをインスタンスに置く」ことは可能ですが、それは「機密情報をディスクに置かない」という当初の目的と矛盾します。EC2ならInstance Profileを素直にアタッチでき、IMDS v2経由で一時クレデンシャルが配られるため、設計の一貫性が保てます。

なお、t4g.small(ARM, 2GB)の月額は約$13、Lightsail small(2GB)は$12。事実上価格差はありません。

なぜECS/Fargateではないか

n8n単一プロセスを動かす目的に対してECS/Fargateでは過剰です。

  • ALBが月$16程度追加で必要になる
  • 永続化のためにはさらにEFSが必要になる
  • シングルコンテナの再起動でセッションが切れる
  • Composeの depends_on のような直感的な依存関係定義ができない

複数のセルフホストツール(n8n + Metabase + Plausibleなど)をまとめて運用する規模になってから検討すれば十分です。今回は単一EC2 + Docker Composeで構築します。

なぜt4g.smallか(nano/microでは不十分)

n8n + PostgreSQLを同居させる場合、アイドル時のメモリ常駐量はおおよそ次の通りです。

コンポーネント アイドル時 ピーク時
Ubuntu 24.04 + systemd 250〜400 MB 同左
Docker daemon + containerd 80〜120 MB 同左
PostgreSQL 16 200〜300 MB VACUUM 時 +200MB
n8n (Node.js) 200〜300 MB ペイロード次第で+500MB〜
Caddy 20〜40 MB 同左
合計 750〜1160 MB 1.5〜2GB に到達

t4g.nano(512MB)はDocker起動時点でOOM、t4g.micro(1GB)はアイドルで収まってもピークでOOMしてしまいます。t4g.small(2GB)なら十分な余裕がある、というのが実測ベースの結論です。

CPUはburstableで十分余裕があるので、手詰まりになるのは常にメモリ側です。月額の差額(smallとmicroで約$6)に対して得られる安定性を考えると、smallで開始するのが妥当です。

なぜPostgreSQLであってMySQLではないか

n8nはバージョン1.0でMySQL/MariaDBのストレージバックエンドサポートを非推奨化し、2.0(2025年12月リリース)で完全に削除しました。現在の最新版は2.x系で、DB_TYPE=mysqldbという指定はもう受け付けられません。

ただし、これは「n8n自身のメタデータ保存先としてのMySQL」が削除されたという話で、ワークフロー内からMySQLに接続するMySQL Nodeは引き続きフルサポートです。手元にAurora MySQLやRDS MySQLがある場合、n8nのワークフローからそれらに接続してSELECT/INSERT/UPDATEする用途は今まで通り使えます。

なお、PostgreSQLに一本化された技術的背景としては、n8nが大量に扱うJSONデータに対するJSONB型とGINインデックスの優位性、LISTEN/NOTIFYやAdvisory Lockの活用しやすさなどがあります。

なぜTerraformやCloudFormationではなくAWS CLIで一つずつ実行するのか

本記事の主目的は、再現性のあるIaCコードを配ることではなく、AWSの各リソースを段階的に作りながら動作と権限の関係を確認していくことです。KMSキー、SSM Parameter Store、IAM Role、Instance Profile、EC2、Elastic IP、Cloudflareの順に1ステップずつ回し、それぞれが想定どおり動くかを都度確認していきます。

このやり方を選んだ理由は次のとおりです。

  • IaCで一気に立ち上げると、どの権限がどこで効いているかが隠れてしまい、AccessDeniedが出たときの切り分けが急に難しくなります
  • IAM Instance ProfileとSSM Parameter StoreとKMSの連動は、書き起こしてみると複雑で、CLIで段階的に叩いた方が「何ができたら次に進めるか」を体感しやすいです
  • セキュリティグループのポート開放、user-dataによるbootstrapの起動順、tmpfs上での機密情報展開といった挙動は、ステップ式に確認したほうが理解が定着します
  • IaC化は、二度目以降の同じ構成の再現や、複数環境への横展開が必要になった段階で初めて投資対効果が出ます

実運用でTerraformやCloudFormationに移すこと自体は否定しません。本記事のコマンドが一通り通ったあと、各リソースをそれぞれのaws_kms_keyaws_ssm_parameteraws_iam_roleaws_iam_role_policyaws_iam_instance_profileaws_security_groupaws_instanceaws_eipに置き換えていけば、そのままTerraformモジュールに昇格できます。最初から抽象化レイヤーで包むより、素のAPI挙動を一度通しておいた方が、後々IaCコードを読み解くときも嵌まりにくくなります。

前提条件と必要なもの

以下を事前に用意してください。

  • AWS アカウント:ルートユーザーでサインインできる状態
  • Cloudflare アカウント:対象ドメイン(本記事では example.com と表記、ご自身のドメインに置き換えてください)のDNSがCloudflareにネームサーバー移譲済み
  • 作業用 PC:macOS/Linux/WSLのいずれか
    • AWS CLI v2がインストール済み
    • jqopensslssh が使える
  • 見積もりコスト:t4g.small + EBS 20GB + Elastic IP + データ転送で月額約$18〜22

ドメインを以後 ${DOMAIN} と表記します。本記事では n8n.example.com を例として使うので、適宜ご自身のサブドメインに置き換えてください。

フェーズ 0: ルートユーザーの封印と管理者IAMユーザー作成

ここはまだAWSマネジメントコンソールで作業します。CLIを使うためのアクセスキーがまだ存在しないので、起点だけはコンソールでセットアップする必要があります。

0.1 ルートユーザーでMFAを有効化

ルートユーザーでサインインし、画面右上のアカウント名から「セキュリティ認証情報」を開きます。

「多要素認証 (MFA)」セクションで「MFA デバイスの割り当て」をクリックし、任意の認証アプリケーションでMFAを登録してください。

0.2 ルートユーザーのアクセスキーを削除

「IAM」→「認証情報レポート」を開き、ルートユーザーにアクセスキーが存在する場合は削除します。ルートのアクセスキーは原理的に作るべきではないので、過去に作っていないことを確認するのが目的です。

0.3 n8n管理者IAMユーザーを作成

「IAM」サービスに移動し、「ユーザー」→「ユーザーを作成」を選択します。

  • ユーザー名 n8n-admin(任意の名前で OK、本記事では n8n-admin を使用)
  • 「AWS マネジメントコンソールへのユーザーアクセスを提供する」にチェック
  • パスワードは自動生成、初回サインイン時に変更を要求

次の画面で「ポリシーを直接アタッチ」を選び、AdministratorAccess をアタッチします。本来は最小権限を心がけますが、最初のブートストラップ段階では管理者権限が便利です。組織で運用する場合はIAM Identity Centerや PermissionSetで絞り込んでください。

0.4 n8n管理者ユーザーで再ログインしてMFAを有効化

ルートユーザーをサインアウトし、n8n-admin でサインインし直します。同じく「セキュリティ認証情報」からMFAを有効化してください。

0.5 n8n管理者ユーザーのアクセスキーを発行してCLIに登録

n8n-admin でサインインしたまま、IAMの「Quick Links」にある「自分の認証情報」を選択し、「アクセスキーを作成」を選びます。

ユースケースは「コマンドラインインターフェイス (CLI)」を選択。発行されたAccess Key IDとSecret Access KeyをローカルのAWS CLIに登録します。

コマンド
aws configure --profile n8n-admin
# AWS Access Key ID: <発行されたもの>
# AWS Secret Access Key: <発行されたもの>
# Default region name: ap-northeast-1
# Default output format: json

以後、すべてのコマンドはこのプロファイルを使います。

コマンド
export AWS_PROFILE=n8n-admin
export AWS_DEFAULT_REGION=ap-northeast-1

# 動作確認
aws sts get-caller-identity
# → Account, UserId, Arn が出れば OK

フェーズ 1: KMSカスタマーマネージドキーの作成

SSM Parameter Storeの SecureString はAWSマネージドKMS キー(alias/aws/ssm)でも暗号化できますが、カスタマーマネージドキーを使うと「特定のロールだけ復号可能」というアクセス制御が可能になります。

コマンド
# 1. KMS キーの作成
KEY_ID=$(aws kms create-key \
  --description "n8n secrets encryption key" \
  --key-usage ENCRYPT_DECRYPT \
  --tags TagKey=Project,TagValue=n8n \
  --query 'KeyMetadata.KeyId' \
  --output text)

echo "Created KMS key: $KEY_ID"

# 2. 人間が読みやすいエイリアスを付与
aws kms create-alias \
  --alias-name alias/n8n-secrets \
  --target-key-id $KEY_ID

# 確認
aws kms describe-key --key-id alias/n8n-secrets \
  --query 'KeyMetadata.[KeyId,Enabled,KeyState,KeyManager]' --output table

KMSキー単体の月額コストは$1、API呼び出し回数に応じた従量課金(10,000リクエストあたり$0.03)が必要になります。SSMパラメータの読み取り頻度は起動時の数回なので、月額は$1前後で収まります。

フェーズ 2: SSM Parameter Storeへの機密情報投入

階層構造を /n8n/prod/... で切ります。これはIAMポリシーで「/n8n/prod/* だけ読める」と書きやすく、将来stagingなどを追加するときに /n8n/staging/* で分離できるからです。

コマンド
# ドメイン名を変数化(ご自身のものに置き換える)
DOMAIN="n8n.example.com"

# 暗号化キーとDBパスワードをローカルで生成
N8N_KEY=$(openssl rand -hex 32)
PG_PASS=$(openssl rand -hex 24)

# SecureStringとして登録(KMSで暗号化)
aws ssm put-parameter \
  --name "/n8n/prod/encryption_key" \
  --value "$N8N_KEY" \
  --type SecureString \
  --key-id alias/n8n-secrets \
  --description "n8n credentials encryption key"

aws ssm put-parameter \
  --name "/n8n/prod/db_password" \
  --value "$PG_PASS" \
  --type SecureString \
  --key-id alias/n8n-secrets

# 平文で良いものはString型でコスト節約
aws ssm put-parameter \
  --name "/n8n/prod/db_user" \
  --value "n8n" \
  --type String

aws ssm put-parameter \
  --name "/n8n/prod/db_name" \
  --value "n8n" \
  --type String

aws ssm put-parameter \
  --name "/n8n/prod/domain" \
  --value "$DOMAIN" \
  --type String

# 投入後、ローカルの変数とシェル履歴を消す
unset N8N_KEY PG_PASS
history -c 2>/dev/null || true

# 確認(値は表示されない、名前だけ)
aws ssm get-parameters-by-path --path "/n8n/prod" \
  --query 'Parameters[].[Name,Type]' --output table

フェーズ 3: インスタンスロールの作成

EC2インスタンスが「/n8n/prod/* のパラメータを読み、alias/n8n-secrets で復号する」だけの最小権限ロールを作ります。

3.1 信頼ポリシー(誰がこのロールを引き受けられるか)

コマンド
cat > /tmp/trust-policy.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "ec2.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}
EOF

aws iam create-role \
  --role-name n8n-instance-role \
  --assume-role-policy-document file:///tmp/trust-policy.json \
  --description "Allows n8n EC2 instance to read SSM parameters"

3.2 権限ポリシー(何ができるか)

Resource/n8n/prod 配下のSSMパラメータに限定し、KMSキーも特定のARNに絞ります。これが最小権限の原則の具体実装です。SSMの GetParametersByPath はパス自身のARNに対して権限評価される非対称仕様があるため、Resource はパス自身と子要素ワイルドカードの2要素配列で書きます。理由はポリシー定義の直後で補足します。

コマンド
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=ap-northeast-1
KMS_KEY_ID=$(aws kms describe-key --key-id alias/n8n-secrets \
  --query 'KeyMetadata.KeyId' --output text)

cat > /tmp/permission-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadN8nParameters",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParameter",
        "ssm:GetParameters",
        "ssm:GetParametersByPath"
      ],
      "Resource": [
        "arn:aws:ssm:${REGION}:${ACCOUNT_ID}:parameter/n8n/prod",
        "arn:aws:ssm:${REGION}:${ACCOUNT_ID}:parameter/n8n/prod/*"
      ]
    },
    {
      "Sid": "DecryptWithN8nKey",
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:${REGION}:${ACCOUNT_ID}:key/${KMS_KEY_ID}"
    }
  ]
}
EOF

aws iam put-role-policy \
  --role-name n8n-instance-role \
  --policy-name n8n-ssm-read \
  --policy-document file:///tmp/permission-policy.json

3.3 Instance Profile(ロールをEC2に貼るための器)

コマンド
aws iam create-instance-profile --instance-profile-name n8n-instance-profile
aws iam add-role-to-instance-profile \
  --instance-profile-name n8n-instance-profile \
  --role-name n8n-instance-role

# IAMの伝播待ち(ベストプラクティスとして数秒待つ)
sleep 15

# 確認
aws iam get-instance-profile --instance-profile-name n8n-instance-profile \
  --query 'InstanceProfile.[InstanceProfileName,Roles[0].RoleName]' --output table

フェーズ 4: bootstrap.shの作成

EC2起動時にuser-dataとして実行されるスクリプトです。機密情報をディスクには永続化せずに、tmpfs(メモリ上のファイルシステム)に展開する設計が肝です。

ローカルの作業ディレクトリで bootstrap.sh を作成します。

コマンド
mkdir -p ~/n8n-deploy && cd ~/n8n-deploy

以下を bootstrap.sh として保存してください。

コマンド
#!/usr/bin/env bash
# ============================================================
# n8n bootstrap with SSM-backed secrets
#
# 設計原則
#   - 機密情報はディスクに永続化しない(再起動時に毎回SSMから取得)
#   - tmpfs(メモリ上のファイルシステム)に一時的にのみ書く
#   - docker composeは環境変数経由で値を受け取る
#   - インスタンスメタデータサービスはIMDSv2のみ使用
# ============================================================
set -euxo pipefail

REGION="ap-northeast-1"
SSM_PREFIX="/n8n/prod"

# ─────────────────────────────────────
# 1. 基本パッケージとAWS CLI v2のインストール
# ─────────────────────────────────────
apt-get update
apt-get install -y ca-certificates curl gnupg unzip jq

# AWS CLI v2(ARM64用)
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ]; then
  AWS_ZIP="awscli-exe-linux-aarch64.zip"
else
  AWS_ZIP="awscli-exe-linux-x86_64.zip"
fi
curl -sSL "https://awscli.amazonaws.com/${AWS_ZIP}" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
/tmp/aws/install
rm -rf /tmp/aws /tmp/awscliv2.zip

# ─────────────────────────────────────
# 2. Docker Engineインストール(公式リポジトリから)
# ─────────────────────────────────────
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg

UBUNTU_CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME")
DEB_ARCH=$(dpkg --print-architecture)
echo "deb [arch=${DEB_ARCH} signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu ${UBUNTU_CODENAME} stable" \
  > /etc/apt/sources.list.d/docker.list

apt-get update
apt-get install -y \
  docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

# Dockerのログローテーション設定(放置するとディスクを埋める)
cat > /etc/docker/daemon.json <<'EOF'
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
EOF
systemctl restart docker

# ─────────────────────────────────────
# 3. n8nの作業ディレクトリ作成
# ─────────────────────────────────────
mkdir -p /opt/n8n

# ─────────────────────────────────────
# 4. SSMから値を取得して環境変数ファイルを生成するスクリプト
#    出力先は/run/n8n-secrets/.env(tmpfsにマウントされる場所)
# ─────────────────────────────────────
cat > /opt/n8n/load-secrets.sh <<'INNER'
#!/usr/bin/env bash
set -euo pipefail

REGION="ap-northeast-1"
SSM_PREFIX="/n8n/prod"
ENV_FILE="/run/n8n-secrets/.env"

# tmpfsが無ければ作る(systemd unitで先に作られているはずだが念のため)
if ! mountpoint -q /run/n8n-secrets; then
  mkdir -p /run/n8n-secrets
  mount -t tmpfs -o size=1M,mode=0700 tmpfs /run/n8n-secrets
fi

# AWS CLI v2はIMDSv2を自動で使ってインスタンスロールの一時クレデンシャルを取得する
# よって明示的にトークンを取らなくても、以下のget-parameters-by-pathはそのまま動く

# SSMから階層配下を一括取得(SecureStringは --with-decryption で復号)
PARAMS=$(aws ssm get-parameters-by-path \
  --region "$REGION" \
  --path "$SSM_PREFIX" \
  --recursive \
  --with-decryption \
  --query 'Parameters[].[Name,Value]' \
  --output text)

# パラメータ名の階層末尾を大文字化して環境変数形式にする
# 例: /n8n/prod/encryption_key → ENCRYPTION_KEY=...
{
  echo "# Auto-generated. Lives only in tmpfs. Do not edit."
  while IFS=$'\t' read -r name value; do
    var_name=$(echo "$name" | sed "s|^${SSM_PREFIX}/||" | tr '[:lower:]' '[:upper:]')
    echo "${var_name}=${value}"
  done <<< "$PARAMS"
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"

echo "Loaded $(($(wc -l < "$ENV_FILE") - 1)) secrets to $ENV_FILE"
INNER
chmod +x /opt/n8n/load-secrets.sh

# ─────────────────────────────────────
# 5. systemdユニット: 起動時にtmpfsを作り、SSMから機密情報を読み込む
# ─────────────────────────────────────
cat > /etc/systemd/system/n8n-secrets.service <<'EOF'
[Unit]
Description=Load n8n secrets from SSM into tmpfs
After=network-online.target
Wants=network-online.target
Before=docker-n8n.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=/bin/mkdir -p /run/n8n-secrets
ExecStartPre=/bin/mount -t tmpfs -o size=1M,mode=0700 tmpfs /run/n8n-secrets
ExecStart=/opt/n8n/load-secrets.sh
ExecStop=/bin/umount /run/n8n-secrets

[Install]
WantedBy=multi-user.target
EOF

# ─────────────────────────────────────
# 6. docker-compose.yml(env_fileはtmpfs を指す)
# ─────────────────────────────────────
cat > /opt/n8n/docker-compose.yml <<'YAML'
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 10

  n8n:
    image: n8nio/n8n:latest
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: ${DB_NAME}
      DB_POSTGRESDB_USER: ${DB_USER}
      DB_POSTGRESDB_PASSWORD: ${DB_PASSWORD}
      N8N_ENCRYPTION_KEY: ${ENCRYPTION_KEY}
      N8N_HOST: ${DOMAIN}
      N8N_PROTOCOL: https
      N8N_PORT: 5678
      # 外部から到達可能な FQDN を明示しないと Webhook が動かない
      WEBHOOK_URL: https://${DOMAIN}/
      GENERIC_TIMEZONE: Asia/Tokyo
      TZ: Asia/Tokyo
      # 実行ログの肥大化を防ぐ(7日で剪定)
      EXECUTIONS_DATA_PRUNE: "true"
      EXECUTIONS_DATA_MAX_AGE: "168"
    volumes:
      - n8n_data:/home/node/.n8n

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    environment:
      DOMAIN: ${DOMAIN}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - n8n

volumes:
  postgres_data:
  n8n_data:
  caddy_data:
  caddy_config:
YAML

# ─────────────────────────────────────
# 7. Caddyfile(自動TLSとリバースプロキシ)
# ─────────────────────────────────────
cat > /opt/n8n/Caddyfile <<'EOF'
{$DOMAIN} {
    reverse_proxy n8n:5678
    encode gzip

    # WebSocket(n8n のエディタ UI で使用)
    @websocket {
        header Connection *Upgrade*
        header Upgrade websocket
    }
    reverse_proxy @websocket n8n:5678
}
EOF

# ─────────────────────────────────────
# 8. docker composeをsystemdで管理
#    n8n-secrets.serviceの後に起動するよう依存関係を張る
# ─────────────────────────────────────
cat > /etc/systemd/system/docker-n8n.service <<'EOF'
[Unit]
Description=n8n via Docker Compose
Requires=docker.service n8n-secrets.service
After=docker.service n8n-secrets.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/n8n
EnvironmentFile=/run/n8n-secrets/.env
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable n8n-secrets.service
systemctl enable docker-n8n.service
systemctl start n8n-secrets.service
systemctl start docker-n8n.service

echo "Bootstrap complete. n8n should be accessible after Caddy obtains TLS cert."

フェーズ 5: EC2インスタンスの起動

5.1 セキュリティグループの作成

コマンド
# デフォルトVPCを使う(分離が必要なら別途VPCを作成)
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=is-default,Values=true" \
  --query 'Vpcs[0].VpcId' --output text)

# セキュリティグループ作成
SG_ID=$(aws ec2 create-security-group \
  --group-name n8n-sg \
  --description "n8n web access" \
  --vpc-id $VPC_ID \
  --query 'GroupId' --output text)
echo "Security Group ID: $SG_ID"

# SSH(22): 自分のIPに絞る
MY_IP=$(curl -s https://checkip.amazonaws.com)
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
  --protocol tcp --port 22 --cidr ${MY_IP}/32

# HTTP(80): CaddyがLet's EncryptのHTTP-01チャレンジに使う
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
  --protocol tcp --port 80 --cidr 0.0.0.0/0

# HTTPS(443): n8nの通信
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
  --protocol tcp --port 443 --cidr 0.0.0.0/0

5.2 SSHキーペアの作成

コマンド
aws ec2 create-key-pair --key-name n8n-key \
  --query 'KeyMaterial' --output text > ~/.ssh/n8n-key.pem
chmod 600 ~/.ssh/n8n-key.pem

5.3 最新のUbuntu24.04 ARM64 AMIを取得

コマンド
AMI_ID=$(aws ec2 describe-images \
  --owners 099720109477 \
  --filters \
    "Name=name,Values=ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-arm64-server-*" \
    "Name=state,Values=available" \
  --query "sort_by(Images, &CreationDate)[-1].ImageId" \
  --output text)
echo "AMI: $AMI_ID"

099720109477 はCanonical(Ubuntuの発行元)のAWSアカウントIDです。

5.4 EC2インスタンスの起動

bootstrap.sh を作成したディレクトリで以下を実行します。

コマンド
INSTANCE_ID=$(aws ec2 run-instances \
  --image-id $AMI_ID \
  --instance-type t4g.small \
  --key-name n8n-key \
  --security-group-ids $SG_ID \
  --iam-instance-profile Name=n8n-instance-profile \
  --metadata-options "HttpTokens=required,HttpEndpoint=enabled,HttpPutResponseHopLimit=2" \
  --block-device-mappings '[{"DeviceName":"/dev/sda1","Ebs":{"VolumeSize":20,"VolumeType":"gp3","Encrypted":true}}]' \
  --user-data file://bootstrap.sh \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=n8n-prod},{Key=Project,Value=n8n}]' \
  --query 'Instances[0].InstanceId' \
  --output text)

echo "Instance ID: $INSTANCE_ID"

オプションの意味は次の通りです。

  • HttpTokens=required でIMDSv2を強制(IMDSv1はSSRF経由のクレデンシャル流出経路になる)
  • Encrypted=true でEBSを暗号化(物理ディスク経由の漏洩防御)
  • VolumeSize=20 は20GB、Postgres + Dockerイメージ + ログには十分

5.5 Elastic IPの割当

EC2のパブリックIPは再起動で変わるので、Elastic IPを割り当てます。これがCloudflare DNSのAレコードが指す先になります。

コマンド
EIP_ALLOC=$(aws ec2 allocate-address --domain vpc \
  --query 'AllocationId' --output text)

aws ec2 associate-address \
  --instance-id $INSTANCE_ID \
  --allocation-id $EIP_ALLOC

PUBLIC_IP=$(aws ec2 describe-addresses --allocation-ids $EIP_ALLOC \
  --query 'Addresses[0].PublicIp' --output text)

echo "Public IP: $PUBLIC_IP"

この $PUBLIC_IP を次のフェーズでCloudflareに登録します。

フェーズ 6: Cloudflare DNSへのA レコード追加

Cloudflareのダッシュボードで対象ドメイン(例: example.com)のDNS管理画面を開きます。

「レコードを追加」をクリックして、

  • タイプ: A
  • 名前: n8n(→ n8n.example.com になる)
  • IPv4 アドレス: 前ステップで取得した $PUBLIC_IP
  • プロキシステータス: DNS のみ(グレー雲) を選択
  • TTL: 自動

「保存」をクリックします。

既存のMXレコードや www のCNAMEレコードには触れません。サブドメインを追加するだけなので、既存のメールやウェブサイトの動作に影響しません。

DNSの伝播を待ちます。

コマンド
DOMAIN="n8n.example.com"
dig +short $DOMAIN
# → 取得したPublic IPが返ってくれば伝播完了

フェーズ 7: 動作確認

7.1 EC2へのSSH接続

コマンド
ssh -i ~/.ssh/n8n-key.pem ubuntu@$PUBLIC_IP

7.2 bootstrapの進行状況を確認

コマンド
# user-dataの実行ログ
sudo tail -f /var/log/cloud-init-output.log

# systemd ユニットの状態
sudo systemctl status n8n-secrets.service
sudo systemctl status docker-n8n.service

# Dockerコンテナの状態
sudo docker ps

bootstrap全体の完了まで3〜5分ほどかかります。docker pscaddyn8npostgres の3コンテナがrunningになっていれば成功です。

7.3 tmpfs上のシークレットファイルを確認

コマンド
sudo ls -la /run/n8n-secrets/
# .envが600権限で存在する

sudo cat /run/n8n-secrets/.env
# DB_USER, DB_NAME, DB_PASSWORD, ENCRYPTION_KEY, DOMAINが見える

# tmpfsであることの確認
mount | grep n8n-secrets
# tmpfs on /run/n8n-secrets type tmpfs (rw,relatime,size=1024k,mode=700)

7.4 CaddyのログでTLS証明書取得を確認

コマンド
sudo docker logs n8n-caddy-1 2>&1 | grep -i "obtain\|certificate"

certificate obtained successfully のような行が出ていれば、Let's Encryptから証明書を取得できています。

7.5 ブラウザで n8n にアクセス

https://n8n.example.com を開きます。初回アクセスでオーナーアカウント作成画面が出るので、メールアドレスとパスワードを設定すれば完了です。

7.6 オーナー作成直後の必須セキュリティ強化

オーナーアカウントを作成した瞬間から、サインアップ画面は二度と表示されなくなります。n8nのcommunity editionは「最初の1人だけがOwnerとして自動登録される」仕様で、以降の追加メンバーはOwnerからの招待が必須になっています。とはいえログインフォーム自体は世界中から到達できるため、ブルートフォースとクレデンシャルスタッフィングへの追加防御を入れておきます。

7.6.1 n8nの2要素認証を有効化する

ログイン後に画面左下の一番下にある「Settings」から「Personal」「Two-factor authentication」と進み、TOTPを登録します。Google AuthenticatorまたはパスワードマネージャのTOTP機能で十分です。発行されたリカバリコードはパスワードマネージャに保存しておきます。これでパスワードが漏れても、TOTPコードがない限りログインできません。

7.6.2 強パスワードに切り替える

初回設定で短いパスワードにしてしまった場合は、同じく「Personal」画面から24文字以上のランダム文字列に差し替えます。パスワードマネージャの自動生成で十分な強度が得られます。

7.6.3 招待機能を封印する(1人運用の場合)

複数人で使う予定がないなら、招待UI自体を無効化できます。「Owner以外の追加メンバーが増える」操作経路がそもそも消えるため、運用面のリスクが一段下がります。

ローカルから新しいパラメータを追加します。

コマンド
aws ssm put-parameter \
  --region ap-northeast-1 \
  --name "/n8n/prod/user_management_disabled" \
  --value "true" \
  --type String

bootstrap.shdocker-compose.yml生成部分にあるn8nサービスのenvironment:に1行足し、変数経由で受け取るようにします。

設定/データ
N8N_USER_MANAGEMENT_DISABLED: ${USER_MANAGEMENT_DISABLED:-false}

EC2側で次の順に流せば反映されます。

コマンド
sudo systemctl restart n8n-secrets.service
sudo systemctl restart docker-n8n.service

load-secrets.shは階層末尾を大文字化する規則なので、/n8n/prod/user_management_disabledUSER_MANAGEMENT_DISABLEDという環境変数名でtmpfsに書き出され、docker compose側のN8N_USER_MANAGEMENT_DISABLEDに流れ込みます。複数人運用に切り替えるときは、SSMの値をfalseに書き換えて同じ手順で再起動するだけで戻せます。

7.6.4 推奨する実施順序

リスクの高い順に閉じていきます。

  1. n8nの2要素認証を有効化する
  2. 強パスワードに切り替える
  3. 1人運用ならN8N_USER_MANAGEMENT_DISABLED=trueを入れる

ここまで終えてから、次の再起動テストに進んでください。

Advanced(CloudflareプロキシとAccessでエディタUIをSSO保護する)

ここまでの設定でも、ログインフォーム自体は世界中から到達できるため、未知の攻撃面が残ります。Cloudflare側に認証層を被せると、未認証のクライアントはn8nのログイン画面にすら到達できなくなり、攻撃面は実質的にWebhookエンドポイントだけに縮みます。実装の骨子だけ示しておきます。

4. CloudflareプロキシをONに戻してOrigin Certificateに切り替える

現在はCloudflareをグレー雲(DNSのみ)にしてCaddyにLet's Encryptで証明書を取らせています。これをオレンジ雲(プロキシON)に戻し、ブラウザからCloudflareまではCloudflareの証明書、CloudflareからEC2まではCloudflare発行のOrigin Certificateで、end-to-end TLS(Full strict)に切り替えます。

おおまかな手順は次のとおりです。

  1. Cloudflareダッシュボードで対象ドメインのSSL/TLSからOverviewを開きFull (strict)を選択する
  2. 同じくSSL/TLSからOrigin Serverに進みCreate CertificateでOrigin Certificateを発行する(PEM形式、有効期限は最長15年が選べます)
  3. 発行された証明書と秘密鍵をSSMにSecureStringとして格納し、load-secrets.shでtmpfs上のファイルとして書き出す
  4. CaddyfileをLet's Encrypt自動取得から、tmpfs上のOrigin Certificateを参照する設定に書き換える
  5. CloudflareのDNS設定でAレコードをグレー雲からオレンジ雲に戻す

CaddyはACME自動取得をやめて、tls /run/n8n-secrets/origin.crt /run/n8n-secrets/origin.keyのように静的なファイル参照に切り替えます。SSM階層に2つの新パラメータ(/n8n/prod/origin_cert/n8n/prod/origin_key)を追加すれば、IAMポリシーのResourceは既に/n8n/prod/*を含んでいるので追加の権限変更は不要です。

5. Cloudflare AccessでエディタUIにSSOを被せる

Cloudflare Zero Trustの無料プラン(最大50ユーザー)でエディタUI全体に認証を被せます。手順の骨子は次のとおりです。

  1. CloudflareダッシュボードからZero Trustに入り無料プランを有効化する
  2. AccessからApplicationsに進みAdd an applicationSelf-hostedを選択する
  3. Application domainに対象ドメインを指定し、Pathは空のままにする
  4. Identity providersOne-time PIN、Google、GitHubのいずれかを有効化する
  5. PolicyでInclude: Emails → 自分のメールアドレスを指定する
  6. Webhook用の別Applicationを作り、Pathに/webhook/*/webhook-test/*を指定したうえでActionにBypassを選ぶ

これを入れると、エディタUIを開いた瞬間にCloudflare側でSSO認証が走り、未認証のクライアントはn8nのログイン画面にすら到達できません。Webhookだけは認証を素通りするので、外部サービスからの呼び出しは従来どおり通ります。

Cloudflare AccessをかけたあとにWebhookが叩けなくなる場合は、Bypass PolicyのPath指定が実際のn8n発行URLと一致していないことがほとんどです。n8n上の対象ワークフローで発行された実Webhook URLのパス構造(/webhook/...または/webhook-test/...)を確認し、両方を登録しておきます。

7.7 再起動テスト(重要)

「機密情報をディスクに置かない」設計が機能していることを確認するため、インスタンスを再起動します。

コマンド
# ローカルから
aws ec2 reboot-instances --instance-ids $INSTANCE_ID

# 1〜2 分待ってから再度SSH
sleep 60
ssh -i ~/.ssh/n8n-key.pem ubuntu@$PUBLIC_IP

# tmpfsに再展開されていることを確認
sudo ls -la /run/n8n-secrets/

# n8nが再起動していることを確認
sudo docker ps

# ブラウザでもアクセスできることを確認

ディスク上には /opt/n8n/docker-compose.yml/opt/n8n/Caddyfile/opt/n8n/load-secrets.sh が残っているだけで、平文の機密情報は一切残っていません。

運用の定石

ここから先は本番運用に入ったあとに必要な仕組みです。

8.1 自動バックアップ

PostgreSQLのダンプをS3に送るcronジョブを仕込みます。

まずS3バケットを作成(コマンド名は全世界中でユニークである必要があるので、適宜変更してください)。

コマンド
BUCKET_NAME="n8n-backup-$(aws sts get-caller-identity --query Account --output text)"
aws s3api create-bucket \
  --bucket $BUCKET_NAME \
  --region ap-northeast-1 \
  --create-bucket-configuration LocationConstraint=ap-northeast-1

# バージョニング有効化(誤削除対策)
aws s3api put-bucket-versioning \
  --bucket $BUCKET_NAME \
  --versioning-configuration Status=Enabled

# サーバーサイド暗号化(SSE-S3)
aws s3api put-bucket-encryption \
  --bucket $BUCKET_NAME \
  --server-side-encryption-configuration '{
    "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]
  }'

# パブリックアクセスをブロック
aws s3api put-public-access-block \
  --bucket $BUCKET_NAME \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

次に、インスタンスロールにS3への書き込み権限を追加します。

コマンド
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

cat > /tmp/backup-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject"],
    "Resource": "arn:aws:s3:::${BUCKET_NAME}/n8n/*"
  }]
}
EOF

aws iam put-role-policy \
  --role-name n8n-instance-role \
  --policy-name n8n-s3-backup \
  --policy-document file:///tmp/backup-policy.json

EC2にSSHして、バックアップスクリプトを設置します。

コマンド
ssh -i ~/.ssh/n8n-key.pem ubuntu@$PUBLIC_IP

sudo tee /opt/n8n/backup.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/n8n

BUCKET_NAME="${BUCKET_NAME}"
TS=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR="/var/backups/n8n"
mkdir -p $BACKUP_DIR

# /run/n8n-secrets/.envからDB認証情報を読み込む
set -a
source /run/n8n-secrets/.env
set +a

# PostgreSQLのカスタム形式ダンプ(pg_restoreで部分復元可能)
docker compose exec -T postgres \
  pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc \
  > "${BACKUP_DIR}/n8n-${TS}.dump"

# S3にアップロード(IAクラスでコスト節約)
aws s3 cp "${BACKUP_DIR}/n8n-${TS}.dump" \
  "s3://${BUCKET_NAME}/n8n/" \
  --storage-class STANDARD_IA

# ローカルは7日分だけ保持
find $BACKUP_DIR -name 'n8n-*.dump' -mtime +7 -delete

echo "Backup complete: ${TS}"
EOF

sudo chmod +x /opt/n8n/backup.sh

# cron で毎日午前3時に実行
echo "0 3 * * * root /opt/n8n/backup.sh >> /var/log/n8n-backup.log 2>&1" \
  | sudo tee /etc/cron.d/n8n-backup

S3のライフサイクルルールで「30日経過したらGlacier、90日で削除」のような長期保管ポリシーも設定できます。

8.2 定期アップデート

n8nあのイメージは比較的高速にアップデートされるため、週次で docker compose pull するジョブを入れます。

コマンド
sudo tee /opt/n8n/update.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/n8n

# バックアップを先に取る(更新で壊れたとき用)
/opt/n8n/backup.sh

# /run/n8n-secrets/.envから認証情報を読み込む
set -a
source /run/n8n-secrets/.env
set +a

# 最新イメージを取得
docker compose pull

# 再起動(変更があれば差分のみ)
docker compose up -d

# 古いイメージを削除
docker image prune -f
EOF

sudo chmod +x /opt/n8n/update.sh

# 毎週日曜午前4時に実行
echo "0 4 * * 0 root /opt/n8n/update.sh >> /var/log/n8n-update.log 2>&1" \
  | sudo tee /etc/cron.d/n8n-update

なお、n8nは時々破壊的変更を含むメジャーバージョンを出します(たとえば、1.0 → 2.0でMySQLバックエンド削除)。完全自動更新は便利ですが、メジャーバージョンアップ前に必ずリリースノートを確認する習慣をつけましょう。

8.3 OSのセキュリティアップデート

Ubuntuには unattended-upgrades パッケージが標準で入っており、デフォルトでセキュリティアップデートを自動適用します。確認だけしておきます。

コマンド
sudo systemctl status unattended-upgrades
# active (running) になっていれば OK

8.4 監査ログ(CloudTrail)

ローカルで以下を実行して、CloudTrailをS3に永続化します。デフォルトでは90日しか履歴が見られません。

コマンド
TRAIL_BUCKET="cloudtrail-$(aws sts get-caller-identity --query Account --output text)"

aws s3api create-bucket \
  --bucket $TRAIL_BUCKET \
  --region ap-northeast-1 \
  --create-bucket-configuration LocationConstraint=ap-northeast-1

# CloudTrailからの書き込みを許可するバケットポリシー
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
cat > /tmp/cloudtrail-bucket-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AWSCloudTrailAclCheck",
      "Effect": "Allow",
      "Principal": {"Service": "cloudtrail.amazonaws.com"},
      "Action": "s3:GetBucketAcl",
      "Resource": "arn:aws:s3:::${TRAIL_BUCKET}"
    },
    {
      "Sid": "AWSCloudTrailWrite",
      "Effect": "Allow",
      "Principal": {"Service": "cloudtrail.amazonaws.com"},
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::${TRAIL_BUCKET}/AWSLogs/${ACCOUNT_ID}/*",
      "Condition": {
        "StringEquals": {"s3:x-amz-acl": "bucket-owner-full-control"}
      }
    }
  ]
}
EOF

aws s3api put-bucket-policy \
  --bucket $TRAIL_BUCKET \
  --policy file:///tmp/cloudtrail-bucket-policy.json

# トレイル作成
aws cloudtrail create-trail \
  --name n8n-account-trail \
  --s3-bucket-name $TRAIL_BUCKET \
  --is-multi-region-trail \
  --enable-log-file-validation

aws cloudtrail start-logging --name n8n-account-trail

これで誰がいつ何を変更したかを後から追えるようになります。月額数百円程度です。

n8nとMySQLの関係

冒頭でも触れましたが、改めてまとめます。

ストレージバックエンドとしてのMySQLは削除された

n8nはv1.0(2023年7月リリース)でMySQL/MariaDBのストレージバックエンドサポートを非推奨化し、v2.0(2025年12月リリース)で完全に削除しました。DB_TYPE=mysqldb という環境変数指定はもう受け付けられません。

すでにMySQLバックエンドで運用していたユーザー向けには、n8nが公式のMigration Toolを提供しており、PostgreSQLまたはSQLiteに移行できます。

PostgreSQLに絞られた背景には、

  • JSONB 型とGINインデックスによるJSON検索の効率
  • LISTEN/NOTIFY を使った分散モードでの通知
  • Advisory Lockによる実行ロック取得

といった、n8nのコア機能とPostgreSQL固有機能の親和性があります。

MySQL Nodeはフルサポート

一方、ワークフロー内から MySQLデータベースに接続するMySQL Node は引き続きフルサポートされています。これはn8n自身のメタデータ保存先の話ではなく、ユーザーが組むワークフローの中で「外部のMySQLからSELECTする」「INSERTする」というアプリケーション側のデータアクセスです。

具体的な用途としては、

  • Aurora MySQLやRDS MySQLのテーブルから日次集計を取得
  • 外部SaaSと既存MySQL データベースの同期
  • 特定テーブルの変更を検知してSlack通知

など、MySQLを「外部システムとして利用する」シナリオは何ら制限がありません。

つまり、すでにMySQLを運用しているチームでも、n8nの自動化レイヤーを「上に被せる」形で導入できます。両者のレイヤー分離は、責務の分離としても自然です。

トラブルシューティング

bootstrapが失敗する

コマンド
# user-dataのログを確認
sudo cat /var/log/cloud-init-output.log

# user-data自体が実行されたか
sudo cat /var/log/cloud-init.log | grep -i user-data

よくある失敗は、

  • リージョン名のタイプミス(ap-northeast-1 であって ap-northeast1 ではない)
  • IAM Roleの伝播待ち不足(インスタンス起動直後はロールがまだアタッチされていないことがある、sleep 15 を入れている理由)
  • KMSキーのリージョン不一致

n8n-secrets.serviceAccessDeniedExceptionで起動しない

sudo journalctl -u n8n-secrets.service --no-pager を確認すると、次のような行が見つかります。

出力例
is not authorized to perform: ssm:GetParametersByPath
on resource: arn:aws:ssm:ap-northeast-1:<ACCOUNT>:parameter/n8n/prod

末尾に//*も付かないパス自身のARNが拒否対象になっているのが特徴です。フェーズ3.2のIAMポリシーのResourceを子要素ワイルドカードparameter/n8n/prod/*だけにしてしまった場合に発生します。GetParameterは子要素ARNで判定されるので個別取得は通る一方、GetParametersByPathはパス自身のARNで判定されるため、片側だけだと弾かれます。

Resourceを以下のように2要素配列に書き直してロールに再適用すれば復旧します。

設定/データ
"Resource": [
  "arn:aws:ssm:${REGION}:${ACCOUNT_ID}:parameter/n8n/prod",
  "arn:aws:ssm:${REGION}:${ACCOUNT_ID}:parameter/n8n/prod/*"
]

再適用後、EC2側でsudo systemctl restart n8n-secrets.serviceを流せばtmpfsへの機密情報展開が成功し、docker-n8n.serviceまで連鎖して立ち上がります。

Caddyが証明書を取得できない

コマンド
sudo docker logs n8n-caddy-1

acme: error: 403 が出ている場合、

  • DNSの伝播がまだ(dig +short $DOMAIN で確認)
  • CloudflareのプロキシがONになっている(DNS のみに変更)
  • 80番ポートがセキュリティグループで閉じている

のいずれかです。Let's Encryptのレートリミット(同一ドメイン週 5 枚)に注意してください。失敗を繰り返すと1週間ロックされてしまいます。

n8nのWebhookが外部から叩けない

WEBHOOK_URL 環境変数が https://${DOMAIN}/ で設定されているか確認してください。これが内部IPやデフォルト値だと、外部から呼べるURLがn8nから発行されません。

メモリ不足でOOMが出る

コマンド
sudo dmesg | grep -i "killed process"
free -h
docker stats --no-stream

t4g.smallで1年運用してメモリ余裕がコンスタントに800MB以上余っていれば、t4g.microへのダウンサイズも検討できます。逆に頻繁に圧迫されている場合、

  • EXECUTIONS_DATA_MAX_AGE を72(3 日)に短縮
  • postgresshared_buffers を64MB に絞る
  • スワップファイル2GBを追加

の順で対策します。

SSMパラメータを更新したのにn8nに反映されない

systemdユニット n8n-secrets.service は起動時にしか実行されません。値を更新したら再ロードが必要です。

コマンド
sudo systemctl restart n8n-secrets.service
sudo systemctl restart docker-n8n.service

コスト試算

東京リージョン(ap-northeast-1)でのおおよその月額です(2026年初頭時点)。

項目 料金
EC2 t4g.small(オンデマンド) 約 $13
EBS gp3 20GB 約 $1.6
Elastic IP 約 $3
KMS カスタマーマネージドキー $1
SSM Parameter Store(Standard) $0
S3(バックアップ、20GB 程度) 約 $0.5
データ転送(送信、月 10GB 程度) 約 $1
CloudTrail 約 $1
合計 約 $18〜20

Reserved InstanceやSavings Plansを1年契約で使うと、EC2部分が30%程度安くなります。

n8n Cloud(マネージド版)のStarterプランが月$20程度(2026年時点)なので、コストだけ見るとほぼ同額です。それでもセルフホストを選ぶ理由は、

  • データの所在を完全にコントロールしたい
  • 同じインフラで他のセルフホストツール(Metabase、Plausibleなど)も同居させたい
  • AWS運用のスキルアップ自体が目的

のいずれかに該当する場合に限ります。「n8nを使えればいい」だけならクラウド版が合理的です。

まとめ

本記事で実装した構成のポイントを整理します。

  • 既存のCloudflare DNSに干渉せず、サブドメインだけを追加してn8nを立ち上げた
  • ルートユーザーを封印し、IAMユーザー で日常運用する基本に立ち返った
  • LightsailではなくEC2 を選ぶことでIAM Instance Profileが使える
  • t4g.small でn8nとPostgresが同居するスイートスポットが構築できる
  • KMS + SSM Parameter Store で機密情報を暗号化保管
  • tmpfsに展開してディスクに平文を残さない
  • IMDSv2強制でSSRF経由のクレデンシャル流出を防御
  • EBS 暗号化で物理ディスク経由の漏洩を防御
  • CaddyでTLS自動取得・更新(運用ノイズ最小化)
  • n8nのMySQLバックエンドはv2.0で削除 されたが、MySQL Nodeは引き続きフルサポート

ここまで作り込むと、おそらく、企業の本番環境に置いても通用するレベルです。個人運用としては過剰かもしれませんが、AWSのセキュリティ設計を体系的に経験するという意味では、この一連の手順を一度通しておく価値はあります。

逆に、この粒度の運用が「学習投資としても割に合わない」と感じる場合は、n8n Cloudで始めて時間を本業に振るのがROI的には正解です。判断は人それぞれですが、判断の根拠が見える状態になっていれば、後から後悔しにくいと思います。

なお本記事はあくまでインフラ層の防御だけを扱っています。この基盤の上にAIエージェントを載せたときの応用層の防御(プロンプト注入、機密情報マスキング、AI生成PRの人間レビュー必須化)は、関連記事の「n8nを使ってAWSの異変はAWSの中で考える」「人が書くのは要件だけ」で扱っています。本記事のSSM階層/n8n/prod/とKMSキーalias/n8n-secrets、IAMポリシーのResource記法は、関連記事の応用層からそのまま引き継いで使われます。

関連記事

参考リンク

Discussion