Akforges
← All posts
n8nAutomationAWSDevOps

Why We Self-Host n8n on AWS (and the Exact Setup We Use)

n8n Cloud is convenient. Self-hosted n8n on your own VPC is cheaper, more controllable, and GDPR/HIPAA-friendly. Here's the exact architecture we deploy for clients.

April 1, 202610 min readAkforges Studio

After deploying n8n for a dozen clients, we almost always recommend self-hosted over n8n Cloud. Not because n8n Cloud is bad — it's a solid product — but because most of our clients hit the ceiling within 3 months.

Here's when self-hosted makes sense and the exact Docker Compose setup we use on AWS.


When to choose self-hosted

Self-hosted is the right call when:

  • You have more than ~500 tasks/day (n8n Cloud pricing scales with tasks; at volume it gets expensive)
  • Your data must stay inside your own network (GDPR, HIPAA, SOC 2, or simply internal policy)
  • You need custom nodes that connect to internal APIs not available in the n8n marketplace
  • You want to pin the n8n version and control your own upgrade schedule
  • You're running long-running workflows that exceed Cloud execution timeouts

n8n Cloud is fine when:

  • You're a small team with simple automations under ~200 tasks/day
  • You have no data residency requirements
  • You don't want to manage infrastructure

For most clients we work with — Series A+ startups and mid-market companies with compliance requirements — self-hosted is the right call. The infra cost is ~$50–150/month versus $50–500/month on Cloud, and you own everything.


The architecture

Here's what we deploy:

AWS VPC
├── EC2 t3.medium (n8n + PostgreSQL + Redis)
├── S3 bucket (workflow backups, binary data)
├── Route 53 + ACM (custom domain + SSL)
└── Security group (443 inbound, 22 from bastion only)

For most clients, a single t3.medium ($33/month) handles 5,000–10,000 ops/day comfortably. If you're running hundreds of concurrent workflows, move PostgreSQL to RDS and Redis to ElastiCache — but start simple.


The Docker Compose file

This is the exact docker-compose.yml we use as a starting point:

version: "3.8"

services:
  n8n:
    image: n8nio/n8n:1.85.0
    restart: always
    ports:
      - "5678:5678"
    environment:
      - N8N_HOST=${N8N_HOST}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - QUEUE_BULL_REDIS_HOST=redis
      - EXECUTIONS_MODE=queue
      - N8N_BASIC_AUTH_ACTIVE=false
      - N8N_USER_MANAGEMENT_JWT_SECRET=${JWT_SECRET}
      - WEBHOOK_URL=https://${N8N_HOST}/
    volumes:
      - n8n_data:/home/node/.n8n
      - ./custom-nodes:/home/node/.n8n/nodes
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started

  n8n-worker:
    image: n8nio/n8n:1.85.0
    restart: always
    command: worker
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - QUEUE_BULL_REDIS_HOST=redis
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - EXECUTIONS_MODE=queue
    volumes:
      - ./custom-nodes:/home/node/.n8n/nodes
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:16-alpine
    restart: always
    environment:
      - POSTGRES_USER=n8n
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=n8n
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: always
    command: redis-server --save 60 1 --loglevel warning
    volumes:
      - redis_data:/data

volumes:
  n8n_data:
  postgres_data:
  redis_data:

Key decisions in this config:

  • Version pinned (1.85.0) — don't use latest in production. Upgrades should be deliberate.
  • Queue modeEXECUTIONS_MODE=queue with a separate worker process. This means the main n8n process handles webhooks and the UI while the worker handles execution. Separating them makes the UI responsive even under heavy load.
  • Custom nodes volume — mount ./custom-nodes so you can deploy custom node packages without rebuilding the image.

nginx config

We put nginx in front of n8n for SSL termination. With AWS Certificate Manager and ALB you can skip nginx — but for the simple single-instance setup, this works:

server {
    listen 443 ssl;
    server_name n8n.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/n8n.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/n8n.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://localhost:5678;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;
        proxy_connect_timeout 75s;
    }
}

The proxy_read_timeout 300s matters — n8n workflows can run for several minutes and the default nginx timeout will kill them.


Backup strategy

n8n workflow definitions are stored in PostgreSQL. We run a daily pg_dump to S3:

#!/bin/bash
# /etc/cron.daily/n8n-backup
DATE=$(date +%Y-%m-%d)
docker exec postgres pg_dump -U n8n n8n | gzip > /tmp/n8n-$DATE.sql.gz
aws s3 cp /tmp/n8n-$DATE.sql.gz s3://your-bucket/n8n-backups/
rm /tmp/n8n-$DATE.sql.gz

Also export workflow JSON files from the n8n UI weekly — they're human-readable and can be re-imported if you ever migrate instances.


Monitoring

Three metrics to watch:

  1. Execution queue depth — if jobs are accumulating faster than workers can process them, you need more workers or a bigger instance.
  2. Failed executions — set up a Slack webhook to notify on workflow failures. n8n has a built-in error workflow trigger for this.
  3. PostgreSQL connection count — if you're running many concurrent workflows, watch for connection exhaustion.

A simple Prometheus + Grafana stack works well here. n8n doesn't expose native Prometheus metrics, but you can query the execution log table directly.


Common mistakes

Using the queue mode without a worker — if you set EXECUTIONS_MODE=queue and don't run a worker container, workflows will queue up and never execute. Check your worker container logs first when debugging.

Not pinning the versionlatest will get you to the newest n8n release on every container restart. A breaking change in a minor n8n version has happened before. Pin the version and test upgrades manually.

Running as root — the official n8n image runs as node user (uid 1000). Don't override this. Volume permission issues are common when the host directory is owned by root.


The cost comparison

For a client running 4,000 ops/day:

| | Monthly cost | |---|---| | n8n Cloud (starter) | ~$420 | | Self-hosted t3.medium | ~$67 | | Self-hosted t3.large | ~$122 |

The difference funds a lot of engineering time for custom nodes.


If you'd rather not manage this yourself, we deploy and maintain self-hosted n8n for clients. Fixed-fee engagement, your VPC, your data.

Work with us

Need help applying this to your stack?

Free 30-min strategy call. We'll scope your problem and tell you honestly what the fix looks like.

Book a strategy call