---
title: How to Host n8n on AWS EKS: A Production-Ready Deployment Guide
description: Deploy n8n workflow automation on AWS EKS with RDS PostgreSQL, ALB ingress, ACM TLS, Secrets Manager, CloudWatch, WAF, and S3 backups. Full production architecture covering HA, encryption, HPA, and Karpenter autoscaling.
url: https://www.factualminds.com/blog/how-to-host-n8n-on-aws-eks-production-guide/
datePublished: 2026-04-22T00:00:00.000Z
dateModified: 2026-04-29T00:00:00.000Z
author: Palaniappan P
category: DevOps & CI/CD
tags: n8n, eks, kubernetes, self-hosted, workflow-automation, aws, helm, devops
---

# How to Host n8n on AWS EKS: A Production-Ready Deployment Guide

> Deploy n8n workflow automation on AWS EKS with RDS PostgreSQL, ALB ingress, ACM TLS, Secrets Manager, CloudWatch, WAF, and S3 backups. Full production architecture covering HA, encryption, HPA, and Karpenter autoscaling.

Most self-hosting guides for n8n stop at `docker run`. That works for a laptop demo but falls apart the moment a pod restarts, an AZ goes down, or your team runs 500 workflows a day. This guide covers the full production path: n8n on Amazon EKS with a Multi-AZ RDS backend, encrypted secrets, WAF-protected ALB ingress, CloudWatch observability, S3 backups, and Karpenter autoscaling — all wired together with IRSA so no AWS credentials ever touch your pods.

> **Running n8n on AWS?** FactualMinds helps teams deploy and operate self-hosted n8n on EKS with production-grade HA, encryption, and observability baked in. [See our AWS Kubernetes services](/services/aws-serverless/) or [talk to our team](/contact-us/).

---

## Architecture Overview

### What You Are Building

A horizontally scalable n8n cluster where the main service handles the UI and API, worker pods process workflow executions from a Redis queue, and all state lives in RDS PostgreSQL. The ALB terminates TLS, WAF screens requests, and External Secrets Operator syncs credentials from Secrets Manager so your Kubernetes cluster never stores raw secrets.

### AWS Services Used

| AWS Service                            | Role                                              |
| -------------------------------------- | ------------------------------------------------- |
| Amazon EKS 1.32                        | Kubernetes control plane and worker nodes         |
| Amazon RDS PostgreSQL 16 (Multi-AZ)    | n8n application database                          |
| Amazon ElastiCache Serverless Redis    | Queue broker for n8n queue mode                   |
| Amazon ECR                             | Private registry for the n8n container image      |
| AWS Application Load Balancer          | Layer-7 ingress with SSL termination              |
| AWS Certificate Manager (ACM)          | TLS certificate, DNS-validated via Route 53       |
| Amazon Route 53                        | DNS alias record pointing to the ALB              |
| AWS Secrets Manager + KMS              | DB password and N8N_ENCRYPTION_KEY storage        |
| AWS WAF v2                             | Managed rule sets protecting the ALB              |
| Amazon CloudWatch + Container Insights | Metrics, logs, and alarms                         |
| Amazon S3                              | Daily workflow JSON exports and alarm log archive |
| AWS IAM / IRSA                         | Pod-level AWS API credentials via ServiceAccount  |
| Karpenter                              | Cost-optimized autoscaling for n8n worker nodes   |
| Amazon VPC                             | Network isolation with private subnets            |

### Traffic and Data Flow

```
Internet
  │
  ▼
AWS WAF v2 (Regional)
  │
  ▼
Application Load Balancer  ←── ACM TLS (HTTPS 443)
  │
  ▼
n8n Service (ClusterIP, port 5678)
  │
  ├─► n8n Main Pods (UI + API + webhook receiver)
  │         │
  │         ├─► RDS PostgreSQL (Multi-AZ, private subnet)
  │         └─► ElastiCache Redis (queue)
  │
  └─► n8n Worker Pods (execution engine)
            │
            ├─► RDS PostgreSQL
            ├─► ElastiCache Redis
            └─► AWS APIs via IRSA (SES, S3, etc.)

External Secrets Operator ──► Secrets Manager ──► Kubernetes Secrets
CloudWatch Agent (Fluent Bit) ──► CloudWatch Logs + Metrics
S3 Backup CronJob ──► S3 (workflow exports)
```

---

## Prerequisites

- AWS CLI v2 configured with admin-level credentials
- `eksctl` v0.180+ installed
- `kubectl` v1.32+
- `helm` v3.14+
- A registered domain in Route 53
- SES identity verified (for the sample workflow)

---

## Step 1: Provision the VPC and EKS Cluster

### VPC Design: Multi-AZ, Private Subnets

Run n8n pods in private subnets across three AZs. Only the ALB lives in public subnets. RDS and ElastiCache are in isolated subnets with no internet route.

### eksctl ClusterConfig

```yaml
# cluster.yaml
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
  name: n8n-prod
  region: us-east-1
  version: '1.32'

vpc:
  cidr: 10.0.0.0/16
  nat:
    gateway: HighlyAvailable
  clusterEndpoints:
    privateAccess: true
    publicAccess: true

managedNodeGroups:
  - name: system
    instanceType: m6i.large
    minSize: 2
    maxSize: 4
    desiredCapacity: 2
    privateNetworking: true
    amiFamily: AmazonLinux2023
    iam:
      withAddonPolicies:
        externalDNS: true
        awsLoadBalancerController: true
        cloudWatch: true

addons:
  - name: vpc-cni
    version: latest
    configurationValues: '{"env":{"ENABLE_POD_ENI":"true"}}'
  - name: coredns
    version: latest
  - name: kube-proxy
    version: latest
  - name: aws-ebs-csi-driver
    version: latest
  - name: amazon-cloudwatch-observability
    version: latest

iam:
  withOIDC: true
```

```bash
eksctl create cluster -f cluster.yaml
```

### Enable OIDC Provider for IRSA

`eksctl` enables OIDC automatically when `iam.withOIDC: true` is set. Verify:

```bash
aws eks describe-cluster --name n8n-prod \
  --query "cluster.identity.oidc.issuer" --output text
```

---

## Step 2: Install the AWS Load Balancer Controller

### IRSA Role for the Controller

```bash
eksctl create iamserviceaccount \
  --cluster n8n-prod \
  --namespace kube-system \
  --name aws-load-balancer-controller \
  --role-name AmazonEKSLoadBalancerControllerRole \
  --attach-policy-arn arn:aws:iam::aws:policy/AWSLoadBalancerControllerIAMPolicy \
  --approve
```

### Install via Helm

```bash
helm repo add eks https://aws.github.io/eks-charts
helm repo update

helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
  --namespace kube-system \
  --set clusterName=n8n-prod \
  --set serviceAccount.create=false \
  --set serviceAccount.name=aws-load-balancer-controller \
  --set region=us-east-1 \
  --set vpcId=$(aws eks describe-cluster --name n8n-prod \
    --query "cluster.resourcesVpcConfig.vpcId" --output text)
```

---

## Step 3: Provision RDS PostgreSQL (Multi-AZ)

### Security Group

```bash
# Allow only EKS worker nodes to reach RDS on 5432
aws ec2 create-security-group \
  --group-name n8n-rds-sg \
  --description "n8n RDS access" \
  --vpc-id $VPC_ID

aws ec2 authorize-security-group-ingress \
  --group-id $RDS_SG_ID \
  --protocol tcp \
  --port 5432 \
  --source-group $EKS_NODE_SG_ID
```

### Create the RDS Instance

```bash
aws rds create-db-instance \
  --db-instance-identifier n8n-prod \
  --db-instance-class db.t4g.medium \
  --engine postgres \
  --engine-version 16.4 \
  --master-username n8nadmin \
  --master-user-password "$(openssl rand -base64 32)" \
  --db-name n8n \
  --allocated-storage 100 \
  --storage-type gp3 \
  --storage-encrypted \
  --kms-key-id alias/aws/rds \
  --multi-az \
  --no-publicly-accessible \
  --vpc-security-group-ids $RDS_SG_ID \
  --db-subnet-group-name n8n-db-subnet-group \
  --backup-retention-period 14 \
  --deletion-protection \
  --tags Key=Project,Value=n8n Key=Env,Value=prod
```

### AWS Backup Policy

```bash
aws backup create-backup-plan --backup-plan '{
  "BackupPlanName": "n8n-rds-daily",
  "Rules": [{
    "RuleName": "DailyBackup",
    "TargetBackupVaultName": "Default",
    "ScheduleExpression": "cron(0 3 * * ? *)",
    "StartWindowMinutes": 60,
    "CompletionWindowMinutes": 180,
    "Lifecycle": {
      "MoveToColdStorageAfterDays": 30,
      "DeleteAfterDays": 90
    },
    "CopyActions": [{
      "DestinationBackupVaultArn": "arn:aws:backup:us-west-2:123456789012:backup-vault:Default",
      "Lifecycle": { "DeleteAfterDays": 30 }
    }]
  }]
}'
```

---

## Step 4: Store Secrets in AWS Secrets Manager

### Create the Secrets

```bash
# DB credentials
aws secretsmanager create-secret \
  --name n8n/prod/db \
  --description "n8n RDS credentials" \
  --kms-key-id alias/n8n-secrets \
  --secret-string '{
    "password": "YOUR_STRONG_PASSWORD",
    "username": "n8nadmin",
    "host": "n8n-prod.cluster-xyz.us-east-1.rds.amazonaws.com"
  }'

# n8n encryption key (32 random bytes, base64)
aws secretsmanager create-secret \
  --name n8n/prod/encryption \
  --description "n8n credential encryption key" \
  --kms-key-id alias/n8n-secrets \
  --secret-string "{\"key\": \"$(openssl rand -base64 32)\"}"
```

### Install External Secrets Operator

```bash
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace \
  --set installCRDs=true
```

### SecretStore and ExternalSecret Manifests

```yaml
# external-secrets.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: n8n
---
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secrets-manager
  namespace: n8n
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: n8n-secrets
  namespace: n8n
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: n8n-secrets
    creationPolicy: Owner
  data:
    - secretKey: DB_POSTGRESDB_PASSWORD
      remoteRef:
        key: n8n/prod/db
        property: password
    - secretKey: DB_POSTGRESDB_HOST
      remoteRef:
        key: n8n/prod/db
        property: host
    - secretKey: N8N_ENCRYPTION_KEY
      remoteRef:
        key: n8n/prod/encryption
        property: key
```

```bash
kubectl apply -f external-secrets.yaml
```

---

## Step 5: Push the n8n Image to ECR

```bash
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=us-east-1
N8N_VERSION=1.35.0
ECR_REPO="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/n8n"

# Create the repository
aws ecr create-repository \
  --repository-name n8n \
  --image-scanning-configuration scanOnPush=true \
  --encryption-configuration encryptionType=AES256

# Pull, tag, and push
aws ecr get-login-password --region $REGION \
  | docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"

docker pull n8nio/n8n:${N8N_VERSION}
docker tag n8nio/n8n:${N8N_VERSION} "${ECR_REPO}:${N8N_VERSION}"
docker push "${ECR_REPO}:${N8N_VERSION}"
```

---

## Step 6: Deploy n8n with Helm

### IRSA Role for n8n Pods

```bash
eksctl create iamserviceaccount \
  --cluster n8n-prod \
  --namespace n8n \
  --name n8n \
  --role-name N8nIRSARole \
  --attach-policy-arn arn:aws:iam::$ACCOUNT_ID:policy/N8nWorkflowPolicy \
  --approve
```

### Helm values.yaml

```yaml
# helm/n8n-values.yaml
image:
  repository: 123456789012.dkr.ecr.us-east-1.amazonaws.com/n8n
  tag: '1.35.0'
  pullPolicy: IfNotPresent

replicaCount: 2

serviceAccount:
  create: false # managed by eksctl above
  name: n8n
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/N8nIRSARole

env:
  - name: N8N_HOST
    value: 'n8n.example.com'
  - name: N8N_PROTOCOL
    value: 'https'
  - name: N8N_PORT
    value: '5678'
  - name: N8N_EDITOR_BASE_URL
    value: 'https://n8n.example.com'
  - name: WEBHOOK_URL
    value: 'https://n8n.example.com'
  # Queue mode — required for >1 replica
  - name: EXECUTIONS_MODE
    value: 'queue'
  - name: QUEUE_BULL_REDIS_HOST
    value: 'n8n-redis.serverless.use1.cache.amazonaws.com'
  - name: QUEUE_BULL_REDIS_PORT
    value: '6379'
  - name: QUEUE_BULL_REDIS_TLS
    value: 'true'
  # Database
  - name: DB_TYPE
    value: 'postgresdb'
  - name: DB_POSTGRESDB_PORT
    value: '5432'
  - name: DB_POSTGRESDB_DATABASE
    value: 'n8n'
  - name: DB_POSTGRESDB_USER
    value: 'n8nadmin'
  - name: DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED
    value: 'true'
  # Telemetry off for air-gapped / privacy-conscious setups
  - name: N8N_DIAGNOSTICS_ENABLED
    value: 'false'

envFrom:
  - secretRef:
      name: n8n-secrets # injected by External Secrets Operator

resources:
  requests:
    cpu: '500m'
    memory: '512Mi'
  limits:
    cpu: '2000m'
    memory: '2Gi'

worker:
  enabled: true
  replicaCount: 2
  resources:
    requests:
      cpu: '500m'
      memory: '512Mi'
    limits:
      cpu: '4000m'
      memory: '4Gi'
  tolerations:
    - key: 'n8n-worker'
      operator: 'Equal'
      value: 'true'
      effect: 'NoSchedule'
  nodeSelector:
    workload: n8n-worker

persistence:
  enabled: false # all state in RDS; no PVC needed

ingress:
  enabled: true
  className: alb
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/certificate-arn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc-123'
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443},{"HTTP":80}]'
    alb.ingress.kubernetes.io/ssl-redirect: '443'
    alb.ingress.kubernetes.io/wafv2-acl-arn: 'arn:aws:wafv2:us-east-1:123456789012:regional/webacl/n8n-waf/xyz'
    alb.ingress.kubernetes.io/healthcheck-path: /healthz
    alb.ingress.kubernetes.io/healthcheck-interval-seconds: '30'
    alb.ingress.kubernetes.io/healthy-threshold-count: '2'
    alb.ingress.kubernetes.io/unhealthy-threshold-count: '3'
    alb.ingress.kubernetes.io/load-balancer-attributes: 'idle_timeout.timeout_seconds=300'
  hosts:
    - host: n8n.example.com
      paths:
        - path: /
          pathType: Prefix

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app.kubernetes.io/name: n8n

podDisruptionBudget:
  enabled: true
  minAvailable: 1
```

```bash
helm repo add n8n https://8n8io.github.io/n8n-helm-chart
helm repo update
helm install n8n n8n/n8n \
  --namespace n8n \
  --version 0.13.0 \
  --values helm/n8n-values.yaml
```

### HorizontalPodAutoscaler

```yaml
# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: n8n-worker
  namespace: n8n
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: n8n-worker
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
```

---

## Step 7: Configure ALB Ingress with ACM TLS

### Request an ACM Certificate

```bash
aws acm request-certificate \
  --domain-name n8n.example.com \
  --validation-method DNS \
  --region us-east-1
```

Add the CNAME record that ACM returns to your Route 53 hosted zone, then wait for the status to become `ISSUED` (usually under 5 minutes).

### Route 53 Alias Record

After the ALB is provisioned by the Load Balancer Controller:

```bash
ALB_DNS=$(kubectl get ingress n8n -n n8n \
  -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')

aws route53 change-resource-record-sets \
  --hosted-zone-id $HOSTED_ZONE_ID \
  --change-batch "{
    \"Changes\": [{
      \"Action\": \"CREATE\",
      \"ResourceRecordSet\": {
        \"Name\": \"n8n.example.com\",
        \"Type\": \"CNAME\",
        \"TTL\": 300,
        \"ResourceRecords\": [{\"Value\": \"${ALB_DNS}\"}]
      }
    }]
  }"
```

---

## Step 8: Attach WAF to the ALB

```bash
# Create WAF WebACL with AWS managed rules
WEB_ACL_ARN=$(aws wafv2 create-web-acl \
  --name n8n-waf \
  --scope REGIONAL \
  --default-action Allow={} \
  --rules '[
    {
      "Name": "AWSManagedRulesCommonRuleSet",
      "Priority": 1,
      "OverrideAction": {"None": {}},
      "Statement": {
        "ManagedRuleGroupStatement": {
          "VendorName": "AWS",
          "Name": "AWSManagedRulesCommonRuleSet"
        }
      },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "CommonRuleSet"
      }
    },
    {
      "Name": "AWSManagedRulesKnownBadInputsRuleSet",
      "Priority": 2,
      "OverrideAction": {"None": {}},
      "Statement": {
        "ManagedRuleGroupStatement": {
          "VendorName": "AWS",
          "Name": "AWSManagedRulesKnownBadInputsRuleSet"
        }
      },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "KnownBadInputs"
      }
    }
  ]' \
  --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=n8n-waf \
  --region us-east-1 \
  --query "Summary.ARN" --output text)
```

The WAF ARN is referenced in the Ingress annotation `alb.ingress.kubernetes.io/wafv2-acl-arn` set in Step 6.

---

## Step 9: CloudWatch Observability

The `amazon-cloudwatch-observability` EKS add-on (installed in Step 1) deploys the CloudWatch Agent and Fluent Bit DaemonSet automatically. Container Insights metrics appear in CloudWatch under `/aws/containerinsights/n8n-prod/performance` within a few minutes.

### Create n8n Alarms

```bash
# Alarm: n8n main pod CPU > 80% for 5 minutes
aws cloudwatch put-metric-alarm \
  --alarm-name "n8n-main-high-cpu" \
  --alarm-description "n8n main pods sustained high CPU" \
  --namespace ContainerInsights \
  --metric-name pod_cpu_utilization \
  --dimensions Name=ClusterName,Value=n8n-prod Name=Namespace,Value=n8n Name=PodName,Value=n8n \
  --statistic Average \
  --period 60 \
  --evaluation-periods 5 \
  --threshold 80 \
  --comparison-operator GreaterThanThreshold \
  --alarm-actions arn:aws:sns:us-east-1:123456789012:n8n-ops

# Alarm: failed executions (requires n8n to push custom metrics via CloudWatch EMF)
aws cloudwatch put-metric-alarm \
  --alarm-name "n8n-failed-executions" \
  --namespace N8N/Executions \
  --metric-name FailedExecutions \
  --statistic Sum \
  --period 300 \
  --evaluation-periods 2 \
  --threshold 10 \
  --comparison-operator GreaterThanThreshold \
  --alarm-actions arn:aws:sns:us-east-1:123456789012:n8n-ops
```

### Log Groups

Fluent Bit routes container logs to `/aws/containerinsights/n8n-prod/application`. Use CloudWatch Insights to query n8n logs:

```
fields @timestamp, log
| filter kubernetes.namespace_name = "n8n"
| filter log like /ERROR|error/
| sort @timestamp desc
| limit 50
```

---

## Step 10: S3 Backup Strategy

### Workflow JSON Export CronJob

```yaml
# workflow-backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: n8n-workflow-backup
  namespace: n8n
spec:
  schedule: '0 2 * * *' # 02:00 UTC daily
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: n8n-backup-sa # IRSA with s3:PutObject
          restartPolicy: OnFailure
          containers:
            - name: backup
              image: amazon/aws-cli:2.15.0
              command:
                - sh
                - -c
                - |
                  set -euo pipefail
                  DATE=$(date +%Y-%m-%d)
                  WORKFLOWS=$(curl -sf \
                    -H "X-N8N-API-KEY: ${N8N_API_KEY}" \
                    "http://n8n.n8n.svc.cluster.local:5678/api/v1/workflows?limit=250")
                  echo "$WORKFLOWS" | aws s3 cp - \
                    "s3://n8n-backups-prod/workflows/${DATE}/all-workflows.json" \
                    --sse aws:kms --sse-kms-key-id alias/n8n-backups
                  echo "Backup complete: workflows/${DATE}/all-workflows.json"
              env:
                - name: AWS_REGION
                  value: us-east-1
              envFrom:
                - secretRef:
                    name: n8n-backup-secrets
```

### S3 Bucket Policy

```bash
aws s3api create-bucket \
  --bucket n8n-backups-prod \
  --region us-east-1

aws s3api put-bucket-versioning \
  --bucket n8n-backups-prod \
  --versioning-configuration Status=Enabled

aws s3api put-bucket-encryption \
  --bucket n8n-backups-prod \
  --server-side-encryption-configuration '{
    "Rules": [{
      "ApplyServerSideEncryptionByDefault": {
        "SSEAlgorithm": "aws:kms",
        "KMSMasterKeyID": "alias/n8n-backups"
      },
      "BucketKeyEnabled": true
    }]
  }'

aws s3api put-public-access-block \
  --bucket n8n-backups-prod \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,\
BlockPublicPolicy=true,RestrictPublicBuckets=true
```

---

## Step 11: Karpenter for Worker Autoscaling

Karpenter provisions nodes on demand as n8n worker pods become unschedulable. Workers are tainted so only n8n workloads land on them.

```yaml
# karpenter-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: n8n-workers
spec:
  template:
    metadata:
      labels:
        workload: n8n-worker
    spec:
      taints:
        - key: n8n-worker
          value: 'true'
          effect: NoSchedule
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1
        kind: EC2NodeClass
        name: n8n-workers
      requirements:
        - key: node.kubernetes.io/instance-family
          operator: In
          values: ['m6i', 'm7i', 'c6i', 'c7i']
        - key: node.kubernetes.io/instance-size
          operator: In
          values: ['large', 'xlarge', '2xlarge']
        - key: karpenter.sh/capacity-type
          operator: In
          values: ['spot', 'on-demand']
        - key: topology.kubernetes.io/zone
          operator: In
          values: ['us-east-1a', 'us-east-1b', 'us-east-1c']
  limits:
    cpu: '200'
    memory: '400Gi'
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 2m
    expireAfter: 168h # rotate nodes weekly
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: n8n-workers
spec:
  amiFamily: AL2023
  role: KarpenterNodeRole-n8n-prod
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: n8n-prod
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: n8n-prod
```

For a deep dive on Karpenter NodePool configuration and Spot consolidation strategy, see our [EKS Karpenter autoscaling guide](/blog/how-to-deploy-eks-karpenter-cost-optimized-autoscaling/).

---

## Network Policies for Zero-Trust Pod Communication

```yaml
# network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: n8n-egress
  namespace: n8n
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: n8n
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              app.kubernetes.io/name: aws-load-balancer-controller
      ports:
        - port: 5678
  egress:
    - ports:
        - port: 5432 # RDS PostgreSQL
          protocol: TCP
        - port: 6379 # ElastiCache Redis
          protocol: TCP
        - port: 443 # AWS APIs (SES, S3, Secrets Manager) and external webhooks
          protocol: TCP
        - port: 53 # CoreDNS
          protocol: UDP
        - port: 53
          protocol: TCP
```

---

## High Availability and Failover Patterns

### Multi-AZ Pod Spread

The `topologySpreadConstraints` in the Helm values ensure n8n main pods spread across AZs. For workers, the Karpenter NodePool already requires three AZ zones. Add this annotation to the worker deployment if you manage it directly:

```yaml
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app.kubernetes.io/name: n8n-worker
```

### RDS Multi-AZ Failover Behavior

When the primary RDS instance fails, AWS promotes the standby in roughly 60–120 seconds. During this window n8n pods will log connection errors. n8n's database client retries connections automatically — active webhook-triggered executions that started before the failover will complete (they hold a connection), but any execution that tries to start during the failover window will be queued in Redis and retried once the DB is reachable again. No workflow data is lost.

### PodDisruptionBudgets

The Helm chart's `podDisruptionBudget.minAvailable: 1` ensures at least one n8n main pod survives node drain events (EKS upgrades, Karpenter consolidation). For workers, apply a separate PDB:

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: n8n-worker-pdb
  namespace: n8n
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: n8n-worker
```

---

## Production Hardening Checklist

| Area                  | Check                                    | Implementation                                                   |
| --------------------- | ---------------------------------------- | ---------------------------------------------------------------- |
| Secrets               | No credentials in env vars or ConfigMaps | External Secrets Operator + Secrets Manager                      |
| Encryption in transit | TLS on all connections                   | ACM on ALB, `SSL_REJECT_UNAUTHORIZED=true` for RDS, TLS on Redis |
| Encryption at rest    | KMS on all storage                       | RDS + S3 + Secrets Manager all use CMK                           |
| Identity              | No static AWS credentials in pods        | IRSA on n8n ServiceAccount                                       |
| Network               | Least-privilege pod communication        | NetworkPolicy for ingress + egress                               |
| Availability          | No single points of failure              | RDS Multi-AZ, HPA, topologySpreadConstraints, PDB                |
| Ingress protection    | WAF + DDoS baseline                      | WAF WebACL with AWSManagedRulesCommonRuleSet                     |
| Image supply chain    | Scan on push, no `latest` tags           | ECR scan on push, pinned semver tag                              |
| Backups               | Tested restore procedure                 | RDS automated backups + S3 workflow exports                      |
| Observability         | Alerts fire before users notice          | CloudWatch Container Insights + custom alarms                    |

For additional practices aligned with AWS Well-Architected, see our [10 AWS DevOps practices for production 2026](/blog/10-aws-devops-practices-production-2026/).

---

## Sample n8n Workflow: CloudWatch Alarm → SES + S3

This workflow shows n8n acting as a smart integration layer between AWS CloudWatch and your operations team. When an alarm fires, n8n emails the team via SES and archives the full alarm payload to S3 for audit.

### Workflow Architecture

```
CloudWatch Alarm
  └── SNS Topic (HTTPS subscription)
        └── n8n Webhook (POST /webhook/cloudwatch-alarm)
              └── IF NewStateValue == "ALARM"
                    ├── AWS SES: Send email to ops team
                    └── AWS S3: Archive full payload as JSON
```

### SNS → n8n Webhook Setup

```bash
# Create SNS topic
SNS_ARN=$(aws sns create-topic --name n8n-cloudwatch-alarms \
  --query TopicArn --output text)

# Subscribe n8n webhook (confirm the subscription in n8n UI after creation)
aws sns subscribe \
  --topic-arn $SNS_ARN \
  --protocol https \
  --notification-endpoint "https://n8n.example.com/webhook/cloudwatch-alarm"

# Attach topic to an existing alarm
aws cloudwatch put-metric-alarm \
  --alarm-name "rds-high-cpu" \
  --alarm-actions $SNS_ARN \
  --ok-actions $SNS_ARN
```

> **SNS HTTP subscription confirmation:** When you subscribe, SNS sends a POST with `Type: SubscriptionConfirmation`. n8n receives this at the webhook node. Add a pre-workflow step that checks for `SubscribeURL` in the body and uses an HTTP Request node to confirm it, or use the n8n SNS trigger node which handles confirmation automatically.

### Workflow JSON

Import this directly into n8n via **Settings → Import Workflow**:

```json
{
  "name": "CloudWatch Alarm → SES + S3",
  "nodes": [
    {
      "id": "node-webhook",
      "name": "CloudWatch Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "parameters": {
        "path": "cloudwatch-alarm",
        "httpMethod": "POST",
        "responseMode": "lastNode",
        "responseData": "firstEntryJson"
      },
      "position": [240, 300]
    },
    {
      "id": "node-parse",
      "name": "Parse SNS Message",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "parameters": {
        "mode": "manual",
        "fields": {
          "values": [
            {
              "name": "alarm",
              "type": "object",
              "objectValue": "={{ JSON.parse($json.body.Message) }}"
            }
          ]
        }
      },
      "position": [460, 300]
    },
    {
      "id": "node-if",
      "name": "Is ALARM State?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true },
          "conditions": [
            {
              "leftValue": "={{ $json.alarm.NewStateValue }}",
              "rightValue": "ALARM",
              "operator": { "type": "string", "operation": "equals" }
            }
          ]
        }
      },
      "position": [680, 300]
    },
    {
      "id": "node-ses",
      "name": "Send SES Alert",
      "type": "n8n-nodes-base.awsSes",
      "typeVersion": 1,
      "parameters": {
        "operation": "sendEmail",
        "toAddresses": "ops@example.com",
        "fromAddress": "alerts@example.com",
        "subject": "=[ALARM] {{ $json.alarm.AlarmName }} in {{ $json.alarm.Region }}",
        "body": "=**Alarm:** {{ $json.alarm.AlarmName }}\n**Region:** {{ $json.alarm.Region }}\n**Description:** {{ $json.alarm.AlarmDescription }}\n**Metric:** {{ $json.alarm.Trigger.MetricName }}\n**Namespace:** {{ $json.alarm.Trigger.Namespace }}\n**Threshold:** {{ $json.alarm.Trigger.Threshold }}\n**Current value:** {{ $json.alarm.Trigger.StatisticType }} {{ $json.alarm.Trigger.Statistic }}\n**State change time:** {{ $json.alarm.StateChangeTime }}\n**New state:** {{ $json.alarm.NewStateValue }}\n**Reason:** {{ $json.alarm.NewStateReason }}"
      },
      "position": [900, 200]
    },
    {
      "id": "node-s3",
      "name": "Archive to S3",
      "type": "n8n-nodes-base.awsS3",
      "typeVersion": 1,
      "parameters": {
        "operation": "upload",
        "bucketName": "n8n-alarm-logs",
        "fileName": "={{ $now.format('YYYY/MM/DD') }}/{{ $json.alarm.AlarmName }}-{{ $now.toISO() }}.json",
        "binaryData": false,
        "fileContent": "={{ JSON.stringify($json.alarm, null, 2) }}"
      },
      "position": [900, 420]
    }
  ],
  "connections": {
    "CloudWatch Webhook": {
      "main": [[{ "node": "Parse SNS Message", "type": "main", "index": 0 }]]
    },
    "Parse SNS Message": {
      "main": [[{ "node": "Is ALARM State?", "type": "main", "index": 0 }]]
    },
    "Is ALARM State?": {
      "main": [
        [
          { "node": "Send SES Alert", "type": "main", "index": 0 },
          { "node": "Archive to S3", "type": "main", "index": 0 }
        ],
        []
      ]
    }
  }
}
```

### IRSA Policy for SES + S3

Attach this to the `N8nIRSARole` created in Step 6:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowSESSend",
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "arn:aws:ses:us-east-1:123456789012:identity/example.com"
    },
    {
      "Sid": "AllowS3AlarmArchive",
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": "arn:aws:s3:::n8n-alarm-logs/*"
    }
  ]
}
```

### Testing End-to-End

```bash
# Manually trigger the SNS notification to test the workflow
aws sns publish \
  --topic-arn $SNS_ARN \
  --message '{
    "AlarmName": "test-alarm",
    "AlarmDescription": "Manual test",
    "Region": "US East (N. Virginia)",
    "NewStateValue": "ALARM",
    "NewStateReason": "Threshold Crossed: 1 datapoint",
    "StateChangeTime": "2026-04-22T10:00:00.000Z",
    "Trigger": {
      "MetricName": "CPUUtilization",
      "Namespace": "AWS/RDS",
      "Statistic": "Average",
      "StatisticType": "Statistic",
      "Threshold": 80
    }
  }'
```

Check the n8n execution log, confirm the SES email arrives, and verify the JSON file appears in `s3://n8n-alarm-logs/`.

---

## Cost Estimate

Estimates are for `us-east-1`, light-to-medium workload (50–200 workflow executions/hour).

| Service                      | Configuration                      | Estimated Monthly Cost |
| ---------------------------- | ---------------------------------- | ---------------------- |
| Amazon EKS                   | Cluster fee                        | ~$73                   |
| EC2 (system nodes)           | 2× m6i.large On-Demand             | ~$140                  |
| EC2 (Karpenter workers)      | 2–4× c6i.large Spot avg            | ~$50–100               |
| RDS PostgreSQL               | db.t4g.medium Multi-AZ, 100 GB gp3 | ~$150                  |
| ElastiCache Serverless Redis | ~0.5 GB cache, light throughput    | ~$30                   |
| ALB                          | Per-hour + LCU charges             | ~$25                   |
| ACM                          | Public certificate                 | Free                   |
| Secrets Manager              | 2 secrets                          | ~$1                    |
| CloudWatch                   | Container Insights + logs + alarms | ~$20–40                |
| S3 (backups + alarm logs)    | <5 GB                              | ~$1                    |
| WAF                          | WebACL + request charges           | ~$10                   |
| **Total**                    |                                    | **~$500–570/month**    |

> Compare this to n8n Cloud Business at ~$50/month for 10,000 executions: once you exceed ~80,000–100,000 executions/month, self-hosting on EKS is cheaper. Below that threshold, n8n Cloud is simpler.

---

## Next Steps

1. Provision the VPC and EKS cluster with the `eksctl` config above — allow 15–20 minutes for the control plane
2. Deploy n8n to a `staging` namespace first, validate queue mode with a test workflow before touching production
3. Point a staging subdomain at the ALB and validate ACM TLS end-to-end
4. Import the CloudWatch alarm workflow, publish a test SNS message, and verify SES delivery and S3 archive
5. Apply the NetworkPolicy manifest and confirm n8n pods can still reach RDS and Redis
6. Set up PodDisruptionBudgets and run a node drain to verify HA behavior before go-live
7. Review our [EKS Karpenter guide](/blog/how-to-deploy-eks-karpenter-cost-optimized-autoscaling/) to tune Spot consolidation for worker nodes
8. If you are still deciding between EKS and ECS for this workload, see our [container orchestration decision guide](/blog/aws-ecs-vs-eks-container-orchestration-decision-guide/)

[**Talk to FactualMinds**](/contact-us/) if you need help with production n8n architecture, IRSA setup, or managed EKS operations. We are an AWS Select Tier Consulting Partner with hands-on EKS experience across regulated industries.

## FAQ

### Can n8n run on AWS EKS without a managed database?
n8n ships with SQLite by default, but SQLite does not survive pod restarts across nodes. On EKS you must use an external database — RDS PostgreSQL is the standard choice. Set DB_TYPE=postgresdb and inject the endpoint via Secrets Manager. Without RDS, workflow history and execution logs are lost on pod replacement.

### How do you make n8n highly available on Kubernetes?
HA n8n on Kubernetes requires: (1) RDS PostgreSQL Multi-AZ, (2) n8n queue mode — set EXECUTIONS_MODE=queue and add ElastiCache Redis so multiple worker pods pick up executions without duplication, (3) two or more replicas spread across AZs with topologySpreadConstraints. Without queue mode, running more than one replica causes duplicate workflow executions.

### What IAM permissions does n8n need on AWS EKS?
Use IRSA (IAM Roles for Service Accounts): create an IAM role with minimum policy (e.g., ses:SendEmail, s3:PutObject for the example workflow) and annotate the n8n ServiceAccount with the role ARN. This avoids embedding AWS credentials in environment variables. The ALB Ingress Controller and External Secrets Operator each need their own IRSA roles.

### How do you encrypt n8n credentials at rest on AWS?
n8n encrypts workflow credentials using N8N_ENCRYPTION_KEY. Store this key in AWS Secrets Manager (encrypted with a KMS CMK) and inject it via External Secrets Operator. Enable RDS encryption with KMS at creation time. This gives two layers: n8n application-level encryption and storage-layer KMS encryption.

### How do you back up n8n data on AWS?
Back up two surfaces: (1) RDS — enable automated backups with 7–35 day retention and use AWS Backup for cross-region copies. (2) Workflow JSON — a Kubernetes CronJob calls the n8n REST API (/api/v1/workflows) and writes the export to S3 daily. Workflow backups are independent of the database and portable across clusters.

### Should you use n8n Cloud or self-host on EKS?
n8n Cloud is simpler to start but lacks VPC integration, custom IAM roles, and has per-execution pricing that scales poorly. Self-hosting on EKS costs 1–2 engineer-days to set up but gives full VPC isolation, IRSA for AWS service calls, unlimited executions at fixed infrastructure cost, and co-location with other internal services.

### What Kubernetes and n8n versions should I use?
As of April 2026: EKS 1.32 (standard support through 2027) and n8n v1.x — pin a specific semver tag such as 1.35.0 using the community Helm chart from github.com/8n8io/n8n-helm-chart. Always pin the Helm chart version in CI to prevent unexpected upgrades.

---

*Source: https://www.factualminds.com/blog/how-to-host-n8n-on-aws-eks-production-guide/*
