Introduction
n8n is one of the most capable workflow automation tools available, and unlike most of its competitors, you can run it entirely on your own infrastructure. That single fact changes the economics and the privacy story completely: your data never leaves a server you control, there's no per-execution metering, and every node — including the ones gated behind paid tiers on hosted plans — is available to you.
The catch is that "self-hosted" means you are now responsible for the parts a SaaS vendor normally handles: the server, the database, TLS certificates, backups, and upgrades. Most tutorials stop at docker run n8n on port 5678, which is fine for kicking the tires and actively dangerous as a production setup.
This guide covers the full path to a real deployment on AWS EC2:
- Launching and sizing an EC2 instance appropriately
- Locking down the security group and assigning a stable IP
- Running n8n with Docker Compose backed by PostgreSQL
- Putting Nginx in front as a reverse proxy — configured correctly for WebSockets
- Issuing and auto-renewing a free Let's Encrypt TLS certificate
- Backing up, upgrading, and troubleshooting the instance
By the end you'll have n8n running at https://n8n.example.com with a valid certificate, a durable database, and webhook URLs that external services can actually call. Once it's live, integrating n8n with AI APIs is a natural next step — that's where a self-hosted instance really starts to pay off.
Why Self-Host n8n on EC2
Self-hosting isn't automatically the right answer — if you run five simple workflows and never touch sensitive data, a hosted plan is less work. Self-hosting wins when one of these applies:
- Data residency and privacy — Workflow payloads, credentials, and execution logs stay inside your own AWS account. For anything touching customer PII, health data, or internal financials, this is often a hard requirement rather than a preference.
- No execution limits — Hosted plans meter workflow executions. Self-hosted n8n has no such cap; your ceiling is the instance's CPU and memory.
- Access to private resources — An EC2 instance can sit inside your VPC and reach private RDS databases, internal APIs, and services in private subnets that a third-party cloud simply cannot route to.
- Predictable cost shape — You pay for compute and storage, not per execution. A workflow that fires ten thousand times a day costs the same as one that fires ten times, which makes high-volume automation viable.
- Full node access — Every node and feature that ships in the community edition is available, with no tier gating.
EC2 specifically is a good fit because you get a persistent disk, a static public IP, and complete control over the network layer — the three things this setup depends on.
What You'll Need
Before starting, have these ready:
- An AWS account with permission to launch EC2 instances and allocate Elastic IPs.
- A domain name where you can add DNS records. We'll use
n8n.example.comthroughout — substitute your own subdomain everywhere it appears. - An SSH key pair for connecting to the instance (you can create one during instance launch).
- Basic terminal comfort — you should be able to SSH into a box and edit files with
nanoorvim.
Familiarity with Docker helps but isn't strictly required; every command is spelled out. If you'd like the fundamentals first, this Docker walkthrough covers images, Compose, and volumes from scratch.
Step 1: Launch the EC2 Instance
In the AWS Console, go to EC2 → Instances → Launch instance and configure the following.
Name and OS. Name it something recognizable like n8n-production. For the AMI, choose Ubuntu Server 24.04 LTS. Ubuntu LTS is well-documented, has current Docker and Certbot packages, and receives security updates for years.
Instance type. This is the decision people most often get wrong. t2.micro (1 GB RAM) is tempting because it falls under the AWS Free Tier, but n8n is a Node.js application and each active workflow execution consumes memory. On 1 GB, instances routinely get killed by the kernel's OOM killer as soon as you run anything involving large payloads or a few parallel executions.
Start with t3.small (2 GB RAM) as a realistic floor for light production use. Move to t3.medium (4 GB) if you plan to process files, run many workflows concurrently, or use AI nodes that hold large responses in memory. Both are burstable instance families, which suits automation's spiky traffic pattern well.
Storage. Use a gp3 root volume of 20–30 GB. The default 8 GB fills up faster than you'd expect once Docker images, Postgres data, and execution history accumulate. gp3 is both cheaper and faster than the older gp2 default.
Key pair. Create a new key pair (or select an existing one) and download the .pem file. Store it somewhere safe — you cannot download it again, and without it you cannot SSH in.
Leave the network settings alone for now; we'll configure the security group properly in the next step. Launch the instance.
Step 2: Security Groups and Elastic IP
Configure Inbound Rules
The security group is your firewall, and it's the single most important security control in this setup. Open EC2 → Security Groups, select the group attached to your instance, and set the inbound rules to exactly this:
| Type | Protocol | Port | Source | Purpose |
|---|---|---|---|---|
| SSH | TCP | 22 | My IP | Administrative access — never 0.0.0.0/0 |
| HTTP | TCP | 80 | 0.0.0.0/0 | Certbot validation + HTTPS redirect |
| HTTPS | TCP | 443 | 0.0.0.0/0 | All real traffic to n8n |
Three things about this table matter more than the rest of this guide:
Port 22 should be restricted to your IP. An SSH port open to the entire internet will start receiving automated credential-stuffing attempts within minutes. If your home IP changes often, use AWS Systems Manager Session Manager instead and close port 22 entirely.
Port 5678 must not appear in this table. That's n8n's default port, and a great many self-hosted instances are reachable directly on it — meaning unencrypted HTTP, with credentials and webhook payloads crossing the network in plaintext. All traffic will reach n8n through Nginx on 443 instead.
Port 80 has to stay open, even though everything redirects to HTTPS. Let's Encrypt validates domain ownership over port 80, and closing it will break certificate renewal about sixty days later — long after you've forgotten why.
Assign an Elastic IP
By default, an EC2 instance's public IP changes every time it stops and starts. Since you're about to point DNS at this address and register webhook URLs with external services, you need it to be permanent.
Go to EC2 → Elastic IPs → Allocate Elastic IP address, then Actions → Associate Elastic IP address and attach it to your instance. Do this before configuring DNS so you never have to wait out propagation twice.
Note that AWS charges for Elastic IPs that are allocated but not attached to a running instance — so release any you stop using.
Step 3: Install Docker and Docker Compose
SSH into the instance using your key and Elastic IP:
chmod 400 your-key.pem
ssh -i your-key.pem ubuntu@YOUR_ELASTIC_IP
Update the system and install Docker using the official convenience script:
sudo apt update && sudo apt upgrade -y
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
Add your user to the docker group so you don't need sudo for every command:
sudo usermod -aG docker $USER
Group membership is only applied at login, so log out and back in for this to take effect:
exit
ssh -i your-key.pem ubuntu@YOUR_ELASTIC_IP
Verify both Docker and the Compose plugin are working:
docker --version
docker compose version
Modern Docker installs include Compose as a built-in plugin, which is why the command is docker compose (a space) and not the older standalone docker-compose binary. If you find tutorials using the hyphenated form, they're targeting the deprecated v1 release.
Step 4: Configure Environment and Compose File
Create the Project Directory
mkdir ~/n8n && cd ~/n8n
Generate Secrets and Write the .env File
Two secrets are needed: a Postgres password and an n8n encryption key. Generate both rather than inventing them by hand:
openssl rand -hex 24 # use for POSTGRES_PASSWORD
openssl rand -hex 32 # use for N8N_ENCRYPTION_KEY
Create the .env file with nano .env:
# Database
POSTGRES_USER=n8n
POSTGRES_PASSWORD=paste_the_first_generated_value_here
POSTGRES_DB=n8n
# n8n — replace with your actual subdomain
N8N_HOST=n8n.example.com
N8N_ENCRYPTION_KEY=paste_the_second_generated_value_here
GENERIC_TIMEZONE=Asia/Kolkata
About N8N_ENCRYPTION_KEY: n8n uses this key to encrypt every stored credential — API keys, OAuth tokens, database passwords. If n8n starts without one it generates a key and writes it into its data volume, which works until the day you restore a backup onto a fresh volume and discover every credential is unreadable. Set it explicitly now and store a copy in a password manager, separate from your database backups.
Restrict permissions so the file isn't world-readable:
chmod 600 .env
Write the Compose File
Create docker-compose.yml with nano docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n:1.107.3
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
- DB_POSTGRESDB_USER=${POSTGRES_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- N8N_HOST=${N8N_HOST}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://${N8N_HOST}/
- N8N_EDITOR_BASE_URL=https://${N8N_HOST}/
- N8N_PROXY_HOPS=1
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- N8N_RUNNERS_ENABLED=true
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=336
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
n8n_data:
Understanding the Configuration
The details here are what separate a working deployment from one that half-works in confusing ways.
ports: - "127.0.0.1:5678:5678" — This is the most important line in the file. Binding to 127.0.0.1 publishes the port on the loopback interface only, so n8n is reachable from Nginx on the same host but not from the internet. Write it as "5678:5678" instead and Docker binds 0.0.0.0, exposing plaintext n8n publicly — and because Docker manipulates iptables directly, it can bypass your security group rules. The narrow bind is what makes the reverse proxy an actual boundary rather than a suggestion.
DB_TYPE=postgresdb — Without this, n8n falls back to SQLite in a file. SQLite is a single-writer database; as soon as several workflows execute concurrently you get lock contention and slow, erratic behavior. Postgres is the supported production backend.
N8N_HOST, N8N_PROTOCOL, and WEBHOOK_URL — n8n uses these to construct the webhook URLs it displays in the editor. Because Nginx terminates TLS, the n8n container only ever sees plain HTTP on 5678 and cannot infer its own public address. Setting N8N_PROTOCOL=https and an explicit WEBHOOK_URL is what makes it generate https://n8n.example.com/webhook/... rather than something with localhost:5678 in it that no external service can reach. The trailing slash on WEBHOOK_URL is expected.
N8N_PROXY_HOPS=1 — Tells n8n it sits behind exactly one trusted proxy, so it reads the client's real IP from the X-Forwarded-For header instead of logging Nginx's loopback address for every request.
depends_on: condition: service_healthy — Plain depends_on only controls start order, not readiness; Postgres accepts a container start long before it accepts connections. Combined with the pg_isready healthcheck, this makes n8n wait for a database that's genuinely ready, avoiding a crash-restart loop on boot.
A pinned image tag — docker.n8n.io/n8nio/n8n:1.107.3 rather than :latest. With latest, any docker compose pull can silently move you across a breaking change. Pinning means upgrades happen when you decide. Check the n8n releases page for the current stable version and use that instead of the tag shown here.
EXECUTIONS_DATA_PRUNE — n8n stores full input and output data for every execution. On a busy instance this grows without bound until the disk fills. This prunes executions older than 336 hours (14 days); adjust to your retention needs.
Start the Stack
docker compose up -d
Check that both containers are healthy:
docker compose ps
docker compose logs -f n8n
You're looking for a line reading Editor is now accessible via: https://n8n.example.com/. Press Ctrl+C to stop following the logs — the containers keep running.
You can confirm n8n is responding locally, though it's not yet reachable from outside:
curl -I http://127.0.0.1:5678
Step 5: Point Your Domain at the Instance
In your DNS provider's dashboard, create an A record:
| Field | Value |
|---|---|
| Type | A |
| Name | n8n (or the full n8n.example.com, depending on the provider) |
| Value | Your Elastic IP |
| TTL | 300 |
Propagation is usually quick but can take longer depending on the previous TTL. Verify from the instance:
dig +short n8n.example.com
When this returns your Elastic IP, continue. Do not proceed to Certbot before DNS resolves correctly — Let's Encrypt validates by connecting to your domain over the public internet, and it will fail if DNS isn't ready yet. Repeated failures count against rate limits.
Step 6: Install and Configure Nginx
Install Nginx
sudo apt install nginx -y
Visiting http://n8n.example.com should now show the default Nginx welcome page, which confirms DNS, the security group, and Nginx are all working together.
Create the Server Block
sudo nano /etc/nginx/sites-available/n8n
server {
listen 80;
server_name n8n.example.com;
# Allow larger payloads for workflows handling file uploads
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
# WebSocket support — required for the n8n editor
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Pass real client information through to n8n
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;
# Long-running workflow executions need generous timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Streaming responses should not be buffered
proxy_buffering off;
proxy_cache off;
}
}
Why Each Directive Matters
The WebSocket block is not optional. proxy_http_version 1.1 plus the Upgrade and Connection headers are what allow the HTTP connection to be upgraded to a WebSocket. n8n's editor uses a persistent connection for live execution updates and node status. Omit these three lines and you get the most common self-hosted n8n complaint: the UI loads, looks fine, and then hangs — workflows appear to never finish, node outputs never populate, and the browser console fills with failed WebSocket errors. The application isn't broken; the proxy is dropping the upgrade.
proxy_set_header Host $host preserves the original hostname. Without it Nginx forwards 127.0.0.1, and n8n's generated URLs and OAuth redirect handling break.
X-Forwarded-Proto $scheme tells n8n the original request arrived over HTTPS, even though the proxied hop is plain HTTP. This pairs with N8N_PROXY_HOPS=1 in the Compose file.
proxy_read_timeout 3600s — Nginx's default is 60 seconds, after which it returns a 504 and closes the connection. Plenty of legitimate workflows run longer than a minute; an hour gives real headroom.
client_max_body_size 50M — The default 1 MB limit causes a 413 Request Entity Too Large on any workflow that receives file uploads. Raise it to match your largest expected payload.
proxy_buffering off — Nginx would otherwise buffer responses before forwarding them, which delays the streaming updates the editor relies on.
Enable the Site
sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Always run nginx -t before reloading. It validates the configuration and reports the exact file and line of any syntax error, which is far easier than debugging a service that refused to come back up. Removing the default symlink prevents the welcome page from shadowing your site.
http://n8n.example.com should now show the n8n setup screen — over plaintext HTTP, which we fix next.
Step 7: Enable HTTPS with Let's Encrypt
Install Certbot and its Nginx plugin:
sudo apt install certbot python3-certbot-nginx -y
Request the certificate:
sudo certbot --nginx -d n8n.example.com
Certbot will ask for an email address (used for expiry warnings), acceptance of the terms of service, and then handle the rest. When it succeeds it rewrites your server block automatically: it adds a listen 443 ssl block with the certificate paths, and converts the original port 80 block into a permanent redirect to HTTPS. You don't need to edit anything by hand.
Verify Auto-Renewal
Let's Encrypt certificates are valid for 90 days. The Certbot package installs a systemd timer that attempts renewal twice daily, but confirm it actually works rather than finding out in three months:
sudo certbot renew --dry-run
sudo systemctl status certbot.timer
A successful dry run means renewal is genuinely wired up. This is also the check that catches a closed port 80 — the most common cause of silent renewal failure.
Reload Nginx and visit your domain:
sudo systemctl reload nginx
https://n8n.example.com should now load with a valid certificate, and http:// should redirect to it.
Step 8: First Login and Owner Account
Open https://n8n.example.com and create the owner account immediately.
A freshly started n8n instance with no owner will hand ownership to whoever reaches the setup screen first. Your instance is now publicly reachable, and automated scanners find new hosts on common subdomains quickly. This is a minutes-matter step, not a later-today step. Use a strong, unique password.
Once you're in, verify the webhook configuration is correct — this is the single best check that your environment variables are right. Create a new workflow, add a Webhook trigger node, and look at the Production URL it displays. It should read:
https://n8n.example.com/webhook/some-generated-id
If you instead see http://localhost:5678/... or a raw IP address, then WEBHOOK_URL, N8N_HOST, or N8N_PROTOCOL isn't being applied. Fix the .env file and restart:
docker compose down && docker compose up -d
This matters because that URL is what you paste into Stripe, GitHub, Typeform, or any other service that needs to call your workflow. A wrong value produces webhooks that silently never arrive.
Finally, confirm the WebSocket path works end to end: run any simple workflow manually and watch for the node status indicators updating live. If execution appears to hang forever, revisit the Upgrade/Connection headers in Step 6.
Backing Up Your n8n Instance
Everything worth protecting lives in two places: the Postgres database (workflows, credentials, execution history) and the encryption key.
Manual Database Dump
cd ~/n8n
docker compose exec -T postgres pg_dump -U n8n n8n > n8n-backup-$(date +%F).sql
Automated Daily Backup
Create ~/n8n/backup.sh:
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/home/ubuntu/n8n-backups"
STAMP=$(date +%F-%H%M)
mkdir -p "$BACKUP_DIR"
cd /home/ubuntu/n8n
# Database dump
docker compose exec -T postgres pg_dump -U n8n n8n | gzip > "$BACKUP_DIR/db-$STAMP.sql.gz"
# n8n data volume (custom nodes, binary data)
docker run --rm \
-v n8n_n8n_data:/data:ro \
-v "$BACKUP_DIR":/backup \
alpine tar czf "/backup/n8n-data-$STAMP.tar.gz" -C /data .
# Retain 14 days
find "$BACKUP_DIR" -type f -mtime +14 -delete
echo "Backup complete: $STAMP"
Make it executable and schedule it:
chmod +x ~/n8n/backup.sh
crontab -e
Add a nightly run at 02:00:
0 2 * * * /home/ubuntu/n8n/backup.sh >> /home/ubuntu/n8n-backups/backup.log 2>&1
Verify the volume name matches your setup — Compose prefixes volumes with the project directory name, so ~/n8n yields n8n_n8n_data. Confirm with docker volume ls.
Two Things That Make Backups Real
Store N8N_ENCRYPTION_KEY separately. A database dump alone is not a restorable backup. Every credential in that dump is encrypted with the key from your .env file. Restore the dump onto a new instance with a different key and the workflows come back while every API key, OAuth token, and database password inside them is permanently unreadable. Keep the key in a password manager, and deliberately not in the same place as the dumps — a single compromised backup archive shouldn't contain both the ciphertext and the key.
Copy backups off the instance. Backups sitting on the same EBS volume as the data they protect don't survive the failure mode you're most worried about. Sync them to S3, or take scheduled EBS snapshots via AWS Backup, or both.
Upgrading n8n Safely
Because the image tag is pinned, upgrades are deliberate:
cd ~/n8n
# 1. Back up first — non-negotiable
./backup.sh
# 2. Edit the image tag to the new version
nano docker-compose.yml
# 3. Pull and recreate
docker compose pull
docker compose up -d
# 4. Watch the logs for migration output or errors
docker compose logs -f n8n
A few notes on upgrade behavior:
- Major version bumps run database migrations on first start. These are generally one-way — rolling back to the previous image after a migration has run is not reliably supported, which is exactly why step 1 exists.
- Read the release notes for the versions you're skipping past, particularly for breaking changes. The n8n project documents these clearly.
- Move one minor version at a time on a production instance rather than jumping many releases at once. It makes any problem far easier to attribute.
- Your data is safe across recreation.
docker compose up -ddestroys and recreates containers, but named volumes persist — which is why the Postgres data and.n8ndirectory live in volumes rather than inside the container.
Troubleshooting Common Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| Editor loads but workflows hang; console shows WebSocket errors | Missing WebSocket headers in Nginx | Add proxy_http_version 1.1, Upgrade, and Connection "upgrade" to the location block, then reload Nginx |
502 Bad Gateway | n8n container isn't running, or Nginx is proxying the wrong port | docker compose ps and docker compose logs n8n; confirm proxy_pass targets http://127.0.0.1:5678 |
Webhook URL shows localhost:5678 | WEBHOOK_URL / N8N_HOST / N8N_PROTOCOL not applied | Correct .env, then docker compose down && docker compose up -d |
| External service reports webhook unreachable | Workflow is inactive, or the test URL was used instead of the production URL | Activate the workflow and use the Production URL from the webhook node |
| n8n container restart loop | Postgres credentials mismatched, or n8n started before the DB was ready | Check docker compose logs n8n; confirm the healthcheck and condition: service_healthy are present |
Container killed unexpectedly; Exit 137 in docker compose ps | Out of memory | Resize the instance, or add swap (see Pro Tips) |
413 Request Entity Too Large | Nginx body size limit | Raise client_max_body_size in the server block |
504 Gateway Timeout on long workflows | Nginx read timeout too short | Raise proxy_read_timeout and proxy_send_timeout |
| Certbot renewal fails | Port 80 closed in the security group | Reopen port 80 to 0.0.0.0/0, then sudo certbot renew --dry-run |
| Credentials broken after restoring a backup | Restored with a different N8N_ENCRYPTION_KEY | Restore the original key; without it, credentials must be re-entered |
| Disk full | Execution history accumulating | Confirm EXECUTIONS_DATA_PRUNE=true; reclaim space with docker system prune -a |
When something misbehaves, docker compose logs -f n8n is almost always the fastest route to the answer. Check it before changing configuration.
Cost Notes
Rather than quote figures that vary by region and change over time, it's more useful to understand what you're actually billed for. Check the AWS Pricing Calculator for current numbers in your region.
Your monthly cost has four components:
- Instance hours — The dominant cost, driven by instance type and how many hours it runs. An always-on automation server runs roughly 730 hours a month.
- EBS storage — Billed per provisioned GB per month, regardless of how much you actually use. A 30 GB gp3 volume costs the same whether it's 10% or 90% full, so don't over-provision.
- Elastic IP — Free while attached to a running instance; billed when allocated and idle. Release IPs you aren't using.
- Data transfer out — Inbound is free. Outbound has a monthly free allowance, after which it's billed per GB. Most automation workloads move small JSON payloads and stay well within it; workflows that move large files are the exception worth watching.
Two ways to reduce cost meaningfully: Savings Plans or Reserved Instances cut the compute rate substantially in exchange for a one- or three-year commitment, which suits an always-on server well. And right-sizing — start at t3.small, watch actual memory use with docker stats, and scale based on evidence rather than guessing upward.
Best Practices
- Restrict SSH to your IP, or close port 22 entirely and use AWS Systems Manager Session Manager, which needs no inbound ports at all.
- Pin your image tag. Reproducible deployments are worth the small friction of editing a version number.
- Set
N8N_ENCRYPTION_KEYfrom day one, and store it separately from your backups. - Never expose port 5678 to the internet or the security group. Bind it to
127.0.0.1and let Nginx be the only entrance. - Enable automatic security updates so the OS patches itself:
sudo apt install unattended-upgrades -y. - Set up EBS snapshots through AWS Backup for point-in-time recovery of the whole volume, in addition to your logical database dumps.
- Add CloudWatch alarms on CPU utilization and disk space. Disk-full is a genuinely common and entirely preventable way for a self-hosted instance to fail.
- Prune execution data. Keep
EXECUTIONS_DATA_PRUNE=truewith a retention window that matches your actual debugging needs. - Consider queue mode when one instance isn't enough. n8n supports a Redis-backed queue with separate worker processes, letting executions scale horizontally. It adds real operational complexity, so reach for it when you've measured a bottleneck — not preemptively.
- Use a staging instance for testing upgrades and risky workflow changes if the automation is business-critical.
🚀 Pro Tips
- Allocate the Elastic IP before configuring DNS. Otherwise you'll set DNS, realize the IP is ephemeral, and wait out propagation a second time.
docker compose logs -f n8nis your first debugging move, always. Most problems announce themselves clearly in the startup logs.- Add swap as cheap OOM insurance on small instances. It won't make n8n fast, but it turns a hard container kill into a slowdown you can notice and respond to:
sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab - Use
N8N_LOG_LEVEL=debugtemporarily when a problem resists diagnosis — then set it back, because debug logging is verbose enough to fill a disk on its own. - Watch resources with
docker statsfor a week before resizing. Real memory numbers beat guesswork in both directions. - Run
nginx -tbefore every reload. It costs a second and prevents a config typo from taking the site down. - Export critical workflows to JSON and commit them to Git. It gives you version history, code review on automation changes, and a recovery path fully independent of your database backups.
- Set
N8N_DIAGNOSTICS_ENABLED=falseif you'd rather the instance not send anonymous usage telemetry.
📌 Key Takeaways
- Docker Compose with Postgres is the production path. SQLite is fine for evaluating n8n, but it's a single-writer database that degrades as soon as workflows run concurrently. Set
DB_TYPE=postgresdbfrom the start rather than migrating under pressure later. - The reverse proxy is the security boundary, and only if you bind correctly.
127.0.0.1:5678:5678keeps n8n on the loopback interface so Nginx on 443 is the sole entrance. Publishing5678:5678instead exposes plaintext n8n publicly and can bypass your security group entirely. - WebSocket headers are what make the editor work.
proxy_http_version 1.1with theUpgradeandConnectionheaders is the difference between a live editor and a UI that loads and then hangs. This single omission accounts for most "self-hosted n8n is broken" reports. N8N_HOST,N8N_PROTOCOL, andWEBHOOK_URLmust match your real HTTPS domain. Behind a TLS-terminating proxy, n8n can't infer its own public address. Get these wrong and it generates webhook URLs no external service can reach.- A backup without the encryption key isn't a backup. Credentials in a Postgres dump are encrypted with
N8N_ENCRYPTION_KEY. Store it in a password manager, separate from the dumps themselves, and copy backups off the instance. - Pin the image tag and back up before upgrading. Major versions run one-way database migrations, so the backup is what makes an upgrade reversible.
Conclusion
You now have a self-hosted n8n instance that's genuinely production-shaped: a properly sized EC2 instance, a locked-down security group, Postgres for durable storage, Nginx terminating TLS with correct WebSocket handling, auto-renewing certificates, and automated backups that include the one secret people usually forget.
The pieces that most often go wrong are the ones worth re-reading: the 127.0.0.1 port bind, the three WebSocket lines in the Nginx config, the WEBHOOK_URL environment variable, and keeping N8N_ENCRYPTION_KEY somewhere you'll still have it after a disaster. Get those right and the rest of this setup is unremarkable in the best way — it just runs.
The best next step is to build something real on it. Wire up a webhook that posts form submissions to your CRM, or a scheduled workflow that summarizes yesterday's data and emails it to you. If you want to go further, integrating n8n with AI APIs walks through adding LLM-powered classification, summarization, and decision-making to workflows on the instance you just built.
And if you'd rather have this designed and deployed for you, that's something I do — see n8n automation services.
References
- n8n Docker Compose Self-Hosting Guide
- n8n Environment Variables Reference
- n8n Configuration for Reverse Proxies
- n8n Releases and Changelog
- AWS EC2 User Guide
- Amazon EBS Volume Types
- Nginx WebSocket Proxying
- Certbot Instructions for Nginx on Ubuntu
- Docker Compose File Reference
- PostgreSQL pg_dump Documentation