Mastodon

Production-Ready n8n on AWS

That will be another microblog-style post about self-hosting on AWS. This time, let's talk about n8n - the workflow automation tool that's been gaining traction in the no-code/low-code world. After successfully deploying GoToSocial on Lightsail (which you can read about here), I decided to tackle something more useful: a production-ready n8n setup that doesn't break the bank.

Why n8n?

n8n is an open-source workflow automation platform that lets you connect different services and APIs. Think Zapier, but self-hosted and with more control. Perfect for automating repetitive tasks, data synchronization, or building complex integrations between your tools.

The problem? Most tutorials show you the "docker run" approach, which is great for testing but terrible for production. Today, we're doing this properly - with security and maintenance in mind.

The Challenge: IPv6 Limitations

Unlike my previous GoToSocial setup, n8n presented a unique challenge. While I'd love to use IPv6-only deployment for cost savings, n8n's webhook functionality and third-party integrations often struggle with dual-stack configurations. Many APIs and services still don't play nicely with IPv6, causing connection issues that are hard to debug. At least at the begining, when I noticed that another service, has issue with dual-stack, I just decided that time spend on solving those issues, is more vauble than 4$ per month.

So, we're sticking with IPv4 for now - but keeping the setup lean and cost-effective.

Infrastructure Setup

For the AWS infrastructure part - again Lightsail - I'm using the same Terraform approach from my previous GoToSocial post. Check out that post for the complete Terraform setup. The infrastructure code is also available in my blog-bin repository.

Why? It's easy - costs! As N8N in community version is rather, monolithic app(not sure about enterprice), autoscaling, or things like that are just hard to apply, at first we need to monitor our app. To undestand our needs! For me cheap 1G of RAM was not enoght, 4GB is much smoother for my scale(around 10 active workflows).

Then, why not use RDS? Again costs, as well as my 'as code' approth. I don't really care about "execution log", as well as everything is ok. Workflows are stored as code, an backuped, credentionals as well, exported and stored in external vault.

At the end Cloudflare, as I'm using it quite heavly, also it comes with a lot of enpoint monitoring benefits, ceritificate managment and tunnels, that is why I feel much safer with it :)

Ah and in case of SSH, the same story as with GoToSocial, Tailscale for direct access. No direct inbound connection to my VM.

Docker Compose: The Right Way My Way

Once your Lightsail instance is running, we need to install n8n properly. Here's my Docker Compose setup that handles everything, so N8N and Tailscale. And yes, it's overcomplicated, however I decided, that I want to be able tweak a lot of properties without touchinf docker-compose structrues. I have healtchecks implemented as well.

 1# templates/docker-compose.n8n.yml.j2
 2
 3name: {{ n8n_project_name | default('n8n') }}
 4
 5services:
 6  n8n:
 7    image: n8nio/n8n:{{ n8n_version | default('1.51.1') }}
 8    container_name: {{ n8n_container_name | default('n8n') }}
 9    restart: unless-stopped
10    user: "{{ n8n_user_id | default('1000') }}:{{ n8n_group_id | default('1000') }}"
11    ports:
12      - "{{ n8n_bind_address | default('127.0.0.1') }}:{{ n8n_port | default('5678') }}:5678"
13    volumes:
14      - n8n_data:/home/node/.n8n
15    environment:
16      - N8N_HOST={{ n8n_host | default('n8n.example.com') }}
17      - N8N_PORT=5678
18      - N8N_PROTOCOL={{ n8n_protocol | default('https') }}
19      - NODE_ENV={{ node_env | default('production') }}
20      - WEBHOOK_URL={{ webhook_url | default('https://n8n.example.com') }}
21      - N8N_USE_DEPRECATED_REQUEST_LIB={{ n8n_use_deprecated_lib | default('true') }}
22      - N8N_LOG_LEVEL={{ n8n_log_level | default('info') }}
23      - N8N_METRICS={{ n8n_metrics | default('false') }}
24      - N8N_SECURE_COOKIE={{ n8n_secure_cookie | default('true') }}
25      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
26      - N8N_RUNNERS_ENABLED={{ n8n_runners_enabled | default('true') }}
27      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS={{ n8n_enforce_settings_permissions | default('true') }}
28{% if n8n_additional_env_vars is defined %}
29{% for env_var in n8n_additional_env_vars %}
30      - {{ env_var }}
31{% endfor %}
32{% endif %}
33    healthcheck:
34      test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5678/healthz || exit 1"]
35      interval: {{ n8n_health_interval | default('30s') }}
36      timeout: {{ n8n_health_timeout | default('10s') }}
37      retries: {{ n8n_health_retries | default('3') }}
38      start_period: {{ n8n_health_start_period | default('40s') }}
39    depends_on:
40      - tunnel
41    networks:
42      - {{ network_name | default('n8n-network') }}
43    logging:
44      driver: "json-file"
45      options:
46        max-size: "{{ n8n_log_max_size | default('10m') }}"
47        max-file: "{{ n8n_log_max_files | default('3') }}"
48
49  tunnel:
50    image: cloudflare/cloudflared:{{ cloudflared_version | default('2024.8.2') }}
51    container_name: {{ tunnel_container_name | default('cloudflared-tunnel') }}
52    restart: unless-stopped
53    user: "{{ tunnel_user_id | default('65532') }}:{{ tunnel_group_id | default('65532') }}"
54    volumes:
55      - tunnel_config:/etc/cloudflared:ro
56    command: {{ tunnel_command | default('tunnel --no-autoupdate run') }}
57    environment:
58      - TUNNEL_TOKEN=${TUNNEL_TOKEN}
59{% if tunnel_additional_env_vars is defined %}
60{% for env_var in tunnel_additional_env_vars %}
61      - {{ env_var }}
62{% endfor %}
63{% endif %}
64    healthcheck:
65      test: ["CMD-SHELL", "cloudflared tunnel info || exit 1"]
66      interval: {{ tunnel_health_interval | default('60s') }}
67      timeout: {{ tunnel_health_timeout | default('10s') }}
68      retries: {{ tunnel_health_retries | default('3') }}
69      start_period: {{ tunnel_health_start_period | default('30s') }}
70    networks:
71      - {{ network_name | default('n8n-network') }}
72    logging:
73      driver: "json-file"
74      options:
75        max-size: "{{ tunnel_log_max_size | default('5m') }}"
76        max-file: "{{ tunnel_log_max_files | default('3') }}"
77
78networks:
79  {{ network_name | default('n8n-network') }}:
80    driver: bridge
81    name: {{ network_name | default('n8n-network') }}
82
83volumes:
84  n8n_data:
85    name: {{ n8n_volume_name | default('n8n_data') }}
86  tunnel_config:
87    name: {{ tunnel_volume_name | default('n8n_tunnel_config') }}

Environment file (templates/n8n.env.j2):

1N8N_ENCRYPTION_KEY={{ n8n_encryption_key }}
2TUNNEL_TOKEN={{ tunnel_token }}

Vars file at group_vars/aws/all.yml

1# Host
2ssh_port: 22
3docker_compose_version: "2.24.5"
4
5# Containers
6n8n_version: 1.102.3
7tunnel_command: 'tunnel run'
8cloudflared_version: latest

I know, i'm using tag latest at cloudflare, but to be honest, they deliver new version each month, and it has always been stable enoght. So i'm finr with it.

Then my vault file group_vars/aws/vault.yaml

 1## N8N
 2vault_n8n_encryption_key: "NEcIZR+seLCfawL3+SNYtD8SlBNLGBR5J21z6aT/U5Y="
 3n8n_host: 'n8n.example.com'
 4webhook_url: 'https://n8n.example.com'
 5vault_tunnel_token: "OWMf9aBbNFh4qxxPcKcOCdeb5I6o9PHbwoSQpc0AWiwLFtilaNWzMOzybyTa2B3qCZX4pG7dlidSUm9sOdT7+d8ALYj+DG0MfB2YPBqX4q/WfQ1bgIHnxi+hYNzA0aDMA9ssuA=="
 6
 7
 8## general info
 9main_user: jwolynko
10main_password: 'jC2hCOQvqoN6CStY7mAY6pdnnPWHVZDfFJxkLXrI7+4='
11user_public_keys:
12  - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJxYZEBNRLXmuign6ZgNbmaSK7cnQAgFpx8cCscoqVed local"
13  - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCfLtBYtg6ulr1qF0crYDhEcHkWvxTRECel7Z1it2bV81+pNpXuEKMKHrL4hn4RBLFe0+kIZr9xQN9NG0B+eErtY9Cc2+dSouszDsvC7vMWYkynh6c8oQutcVCvGPKYSh5SgJLrNOOrAXRyHP7Gtj1Nnoih/Ci32JSEt9s8V6ojaSc/xUbFbhvCFuVDHJ7hWf59gk91KjaeuS7cQcdX6/QpwsITFkd3j77rPU31rW/YDJycwQzgMRw7gPzgo/qKVHde7QY1fvrl1JG1rdogLIZeGCAd3ljgrk8pIVQoTAFxBWnSyQHEVSUiLniXfDOIYbss8SFNxe3loFZ44jWjG/KbIlATKXM2iOA3SsSJBI4AjrjoQAy6til3t0C+TE18q4Ip2mSa1YXPYPZ4tFyHohP1DLEDeq4KQeecoZHjkzUTb0aDaEWwAtV2PBnxu7lwQNqBMgiTLN52s9zPl11kfVAkbDzugVJKIiqm/m2IkamlCRlmjf6Gs/WA/wwx0XMab6zmvKiMnjeLJzqcKA0O2pC8yN3f4kL1OJ/5W80bX9HhzR5JslpD3DQMK7f8XaMynBYdTiixD2SJndJvbdcGulxMaednCj03+EWw7fTauc38HfADp19L+lFBf3eIA3KWojXR+f4veLY3Ktnzzm0HI2/hKFu6pQntl4VdshdeuNa4RQ== backup"

Then I have my quite simple playbook:

 1---
 2- name: Deploy N8N with Docker Compose
 3  hosts: aws
 4  become: yes
 5  vars:
 6    n8n_directory: "/opt/n8n"
 7    n8n_encryption_key: "{{ vault_n8n_encryption_key | default('your-32-char-encryption-key-here') }}"
 8    tunnel_token: "{{ vault_tunnel_token }}"
 9
10  tasks:
11    - name: Create n8n directory
12      ansible.builtin.file:
13        path: "{{ n8n_directory }}"
14        state: directory
15        mode: '0755'
16        owner: root
17        group: root
18
19    - name: Create .env file for Docker Compose
20      ansible.builtin.template:
21        src: n8n.env.j2
22        dest: "{{ n8n_directory }}/.env"
23        mode: '0600'
24        owner: root
25        group: root
26      notify: restart n8n
27
28    - name: Create /etc/docker directory
29      file:
30        path: /etc/docker
31        state: directory
32        mode: '0755'
33
34
35    - name: Install required packages
36      ansible.builtin.package:
37        name:
38          - python3-pip
39        state: present
40        update_cache: yes
41 
42    - name: Deploy docker-compose.yml from template
43      ansible.builtin.template:
44        src: docker-compose.n8n.yml.j2
45        dest: "{{ n8n_directory }}/docker-compose.yml"
46        mode: '0644'
47        owner: root
48        group: root
49      notify: restart n8n
50
51    - name: Configure ping group range for cloudflared
52      ansible.posix.sysctl:
53        name: net.ipv4.ping_group_range
54        value: "65532 65534"
55        state: present
56        sysctl_file: /etc/sysctl.d/99-cloudflared.conf
57        reload: true
58
59    - name: Start Docker service
60      ansible.builtin.systemd:
61        name: docker
62        state: started
63        enabled: yes
64
65    - name: Deploy N8N with Docker Compose
66      community.docker.docker_compose_v2:
67        project_src: "{{ n8n_directory }}"
68        state: present
69        pull: missing
70        remove_orphans: yes
71        env_files: "{{ n8n_directory }}/.env"
72      register: compose_result
73
74    - name: Show deployment result
75      ansible.builtin.debug:
76        var: compose_result
77
78  handlers:
79    - name: restart n8n
80      community.docker.docker_compose_v2:
81        project_src: "{{ n8n_directory }}"
82        state: present
83        env_files: "{{ n8n_directory }}/.env"

Inventory file (inventory.yml):

1aws:
2  hosts:
3    n8n:
4      ansible_python_interpreter: python3

Where I'm using regular reference from ~/.ssh/config.

Deployment

Running the deployment is straightforward:

 1# Clone your setup
 2git clone https://codeberg.org/3sky/micro-n8n.git
 3cd micro-n8n
 4
 5# Make a vault and populate with you data
 6ansible-vault create group_vars/aws/vault.yaml
 7
 8# Execute the playbook as root, to prep system
 9ansible-playbook -i inventory.yaml baseline.yaml --ask-vault-pass
10
11# Execute the playbook as user, to install n8n
12ansible-playbook -i inventory.yaml deploy-n8n.yaml --ask-vault-pass -K

What to do when new version come in? Just update group_vars/aws/all.yaml and trigger:

1ansible-playbook -i inventory.yaml deploy-n8n.yaml --ask-vault-pass -K

Security Considerations

This setup includes several security layers:

  1. Lightsail firewall: No inbound connection allowed, after initial configuration
  2. Docker isolation: Services run in isolated containers
  3. Secrets management: Environment variables, not hardcoded values
  4. Ansible Vault: For storing secret in some kind encrypted solution

Overall Consideration

This setup my be diffrent from my other solutions:

  1. Ubuntu: I'm not using CentOS here, I want to use docker in more simple way. Why? My idea was to deliver it to customer and give them to manage. And Ubutnu with Docker is simpler than CentOS and Podman quadlets.
  2. Lightsail: Again costs, and simplecity. It could be anything else based on customer needs. It must be easy to migrate, replicate, rebuild.
  3. Simple playbooks: Assumtion was to deliver bussines value fast, and in stable way. The only important think is N8N version, as we want to keep it fresh all the time - endpoints are exposed to public internet.
  4. VPN: For production setup, using diffrent VPN solution is very possible, that is why it's not covered here.
  5. Cloudflare: Ok, here we can use Nginx, but again network constrains, etc. And exposing application directly to interent is always thing to consider twice.

Cost Breakdown

Here's the real monthly cost:

  • Lightsail with 4GB of RAM and IPv4: $24/month
  • Total: ~$24/month

Almost cheaper than Zapier Pro at $20/month or hosted N8N, and you get unlimited workflows! Also you can go with smaller instance first, and see your usage.

What's Next?

This post covered the foundation - getting n8n running securely on AWS with proper automation. In the next part, we'll dive into:

  • Monitoring and Alerting: CloudWatch integration, custom metrics, and alerting strategies
  • Backup and Recovery: Automated database backups and disaster recovery procedures
  • Advanced Security: WAF integration, VPC endpoints, and compliance considerations

Summary

We've built a production-ready n8n deployment using modern DevOps practices. The key takeaways:

  • Infrastructure as Code: Terraform for AWS resources
  • Configuration Management: Ansible for reproducible deployments
  • Container Orchestration: Docker Compose for service management
  • Security First: SSH, firewalls, and proper secret management
  • Cost Optimization: Under $40/month for a production-ready setup with AI tokens

The complete code is available in my micro-n8n repository. This setup is battle-tested - I'm using it for client automation projects and it's been rock-solid.

Self-hosting n8n gives you complete control over your workflows, data, and integrations. Plus, at under $25/month, it's significantly cheaper than most SaaS alternatives once you start scaling.

Host your own automation, it's simpler than you think.

Quick Note on Snippet Management

At the end, I wanted to mention that I've finally found my gold standard for snippet management: NextCloud Notes with Neovim. The combination of cloud sync, instant share, markdown support, and vim keybindings has transformed my consultant workflow. I'll be writing a dedicated post about this setup soon - it's a game-changer for anyone juggling multiple projects and code snippets.