Self-Hosting n8n on Azure
Self-Hosting n8n on Azure
The guide I wish existed before I built all of this the hard way.
Why This Guide Exists
A dear friend of mine runs her whole AI content operation on n8n… email drafts, image generation, social posting, the lot. Last week her hosting provider went down for seven hours. No warning. No updates. No apology that landed. Just a spinning wheel while her automations sat dark and her inbox quietly piled up.
She texted me. “Is this really how this is supposed to work?”
No. It isn’t.
Here’s the thing nobody tells you when you first spin up n8n on whatever $5/month VPS was at the top of your search results… your automation stack is only as reliable as the floor it’s sitting on. And most of those floors are built for hobbyists, not for people running real workflows that touch real customers.
The good news is, the fix isn’t complicated. It’s not even that expensive. For roughly the price of a decent dinner each month… you can move your n8n to Azure, bolt on automatic backups, get real uptime monitoring, and never lose a day to someone else’s data center hiccup again.
This is that guide. Everything I learned standing up n8n on Azure for Quietly Working, now written down so you don’t have to learn it the same way I did.
By the end of this, you will have:
- A real Azure VM running Ubuntu, sized appropriately for n8n
- n8n self-hosted Community Edition (free forever, no execution limits)
- A proper domain with automatic HTTPS
- PostgreSQL as your n8n database (not SQLite… we’ll talk about why)
- Nightly backups to Azure Blob Storage, rotating 7 daily + 4 weekly
- Firewall, brute-force protection, and automatic security patches
- Uptime monitoring that calls your phone when things break
- A shell script that does roughly 80% of the setup for you
- A Claude Desktop prompt set so your AI can troubleshoot alongside you
Let’s build it.
What It Costs
I’m going to show you the real numbers upfront because I hate guides that bury this.
| Item | Monthly Cost | Notes |
|---|---|---|
| Azure VM (B2s: 2 vCPU, 4 GB RAM) | ~$30 | Handles solo + small team workloads easily |
| Azure Managed Disk (64 GB SSD) | ~$5 | Pre-installed on the VM |
| Azure Blob Storage (backup target) | ~$1 | For nightly postgres dumps |
| Outbound bandwidth | ~$1 | Assumes reasonable usage |
| Domain name (if you don’t have one) | ~$1 | ~$12/year averaged |
| Uptime monitoring (Betterstack free tier) | $0 | Free plan covers 10 monitors |
| Total | ~$38/month |
Compare this to a cheap VPS at $6/month and you’re paying a $32 premium for:
- A hyperscaler data center (not a reseller’s reseller)
- Automatic OS-level snapshots
- Transparent pricing and billing
- SLA you can actually read
- A company that’ll still exist in five years
Worth it. Every time.
If you want to scale up later (team of 5+, heavy workflows), jump to a B4ms (4 vCPU, 16 GB RAM) which runs about $120/month. Don’t start there. You probably don’t need it.
Prerequisites
Before you start, have these ready:
- Azure account — You can get a new account with $200 free credit for 30 days. If you’re a nonprofit, check if you qualify for Azure for Nonprofits (up to $3,500/year in credits… yes, really).
- A domain name — Even a cheap one at
.xyzworks fine. You want to run n8n atn8n.yourdomain.com, not an IP address. - DNS control — Ideally Cloudflare (free, fast, safe defaults). If your domain is elsewhere, you can still use it, you’ll just manage DNS there.
- SSH client — On Windows, use Windows Terminal or Git Bash. On Mac/Linux, your built-in terminal.
- About 90 minutes for the full walkthrough. Half that if you use the shell script.
That’s it. No prior Azure experience needed. No Docker deep knowledge. No networking magic. I’ll explain everything you do and why you’re doing it.
Part 1: Create Your Azure VM
Open portal.azure.com and sign in.
1.1 Create a Resource Group
A resource group is just a folder for everything related to this project. Makes it easy to clean up later if you ever need to.
- Search bar at top → “Resource groups” → + Create
- Name:
n8n-production - Region: pick the one closest to you (I use
West US 2) - Review + Create → Create
1.2 Create the VM
- Search bar → “Virtual machines” → + Create → Azure virtual machine
- Fill in:
- Resource group:
n8n-production - Virtual machine name:
n8n-vm - Region: same as your resource group
- Availability options: No infrastructure redundancy required (you’re solo, this is fine)
- Security type: Standard (Trusted Launch adds complexity you don’t need yet)
- Image: Ubuntu Server 22.04 LTS - x64 Gen2
- VM architecture: x64
- Size: click “See all sizes”, search for B2s. Select
Standard_B2s(2 vCPU, 4 GB RAM, ~$30/mo) - Authentication type: SSH public key
- Username:
azureuser(default is fine) - SSH public key source: Generate new key pair (Azure will give you a private key to download)
- Key pair name:
n8n-vm_key - Public inbound ports: Allow selected ports → check SSH (22)
- Resource group:
- Disks tab:
- OS disk type: Standard SSD (Premium is overkill for n8n)
- OS disk size: 64 GiB (default 30 is tight once backups start piling up)
- Networking tab: defaults are fine. A new virtual network and public IP will be created.
- Review + Create → Create
- When prompted, download the private key (
n8n-vm_key.pem). Save it somewhere safe. Losing this locks you out of your VM.
Wait 2-3 minutes for deployment. Azure will show you the public IP address once it’s ready. Copy that… we’ll need it in a second.
1.3 Open HTTP and HTTPS Ports
Your VM only allows SSH right now. We need ports 80 and 443 open so Caddy can serve HTTPS.
- Go to your VM → Networking → Network settings
- Click + Create port rule → Inbound port rule
- Service: HTTP → Add
- Repeat for HTTPS
You should now have three inbound rules: SSH (22), HTTP (80), HTTPS (443).
1.4 First SSH Connection
On Windows (PowerShell or Git Bash):
icacls.exe "C:\path\to\n8n-vm_key.pem" /reset
icacls.exe "C:\path\to\n8n-vm_key.pem" /grant:r "$($env:USERNAME):(R)"
icacls.exe "C:\path\to\n8n-vm_key.pem" /inheritance:r
ssh -i "C:\path\to\n8n-vm_key.pem" azureuser@YOUR_VM_IP
On Mac/Linux:
chmod 400 ~/Downloads/n8n-vm_key.pem
ssh -i ~/Downloads/n8n-vm_key.pem azureuser@YOUR_VM_IP
First connection will ask you to confirm the host fingerprint. Type yes. You’re in.
Part 2: Point Your Domain at the VM
Before we install anything, let’s get DNS right so Caddy can get an SSL certificate automatically.
2.1 Create an A Record
In Cloudflare (or your DNS provider):
- Add an A record
- Name:
n8n(this makesn8n.yourdomain.com) - IPv4 address: your Azure VM public IP
- Proxy status: DNS only (grey cloud, not orange)
- Why: Caddy needs to talk directly to Let’s Encrypt to issue certs. Cloudflare’s proxy can interfere during issuance. You can turn the orange cloud on later if you want, once everything’s working.
- TTL: Auto
Wait 1-2 minutes for propagation, then test:
# From your laptop, not the VM
nslookup n8n.yourdomain.com
You should see your VM’s IP. If not, wait another minute and try again.
Part 3: Install n8n With Docker Compose
Back in your SSH session to the VM…
3.1 Update the System and Install Docker
sudo apt update && sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg ufw fail2ban unattended-upgrades
# Install Docker
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker azureuser
# Log out and back in so the group change takes effect
exit
SSH back in, then verify:
docker --version
docker compose version
Both should return version numbers.
3.2 Create the n8n Directory Structure
mkdir -p ~/n8n/{data,postgres,backups/daily,backups/weekly,scripts}
cd ~/n8n
3.3 Generate Encryption Keys
n8n encrypts credentials at rest with a key you control. Generate one now and save it somewhere safe (a password manager is perfect).
openssl rand -hex 32
Copy that output. You’ll paste it into .env in the next step.
Also generate a strong postgres password:
openssl rand -base64 24 | tr -d '\n'
3.4 Create the .env File
nano ~/n8n/.env
Paste this in, replacing the placeholder values:
# n8n core
N8N_ENCRYPTION_KEY=PASTE_YOUR_32_BYTE_HEX_KEY_HERE
N8N_HOST=n8n.yourdomain.com
N8N_PROTOCOL=https
N8N_PORT=5678
WEBHOOK_URL=https://n8n.yourdomain.com/
GENERIC_TIMEZONE=America/Los_Angeles
# CRITICAL: without this, $env access fails in workflow expressions
N8N_BLOCK_ENV_ACCESS_IN_NODE=false
# Postgres
POSTGRES_USER=n8n
POSTGRES_PASSWORD=PASTE_YOUR_POSTGRES_PASSWORD_HERE
POSTGRES_DB=n8n
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=n8n-postgres
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=n8n
DB_POSTGRESDB_PASSWORD=PASTE_YOUR_POSTGRES_PASSWORD_HERE
# Your email (for SSL cert notifications)
ACME_EMAIL=[email protected]
DOMAIN=n8n.yourdomain.com
Save with Ctrl+O, Enter, Ctrl+X.
Lock the file so only you can read it:
chmod 600 ~/n8n/.env
⚠️ The
N8N_BLOCK_ENV_ACCESS_IN_NODE=falseline is not optional. Without it, newer n8n versions will block$env.YOUR_VARaccess inside workflow expressions, and about 80% of real-world workflows will silently fail. Ask me how I know.
3.5 Create docker-compose.yml
nano ~/n8n/docker-compose.yml
Paste this:
services:
n8n-postgres:
image: postgres:16-alpine
container_name: n8n-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
depends_on:
n8n-postgres:
condition: service_healthy
env_file: .env
ports:
- "127.0.0.1:5678:5678"
volumes:
- ./data:/home/node/.n8n
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- n8n
volumes:
caddy_data:
caddy_config:
A couple things to notice:
- n8n binds only to
127.0.0.1:5678… the VM firewall will never see it on a public port. Only Caddy can reach it. Security by design. - Postgres has a health check. n8n won’t start until postgres is actually ready to accept connections. Skip this and you’ll get mysterious first-boot errors.
3.6 Create the Caddyfile
Caddy is the reverse proxy that terminates HTTPS. It automatically gets and renews Let’s Encrypt certificates. No certbot. No cron jobs. It just works.
nano ~/n8n/Caddyfile
{$DOMAIN} {
reverse_proxy n8n:5678
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
3.7 Start Everything
cd ~/n8n
docker compose up -d
Watch it come up:
docker compose logs -f caddy
You want to see lines like certificate obtained successfully. Once that appears, press Ctrl+C to exit the log stream.
Visit https://n8n.yourdomain.com in your browser. You should get the n8n setup screen. 🎉
Create your owner account immediately. Pick a strong password.
Part 4: Hardening the VM
n8n is running. Now let’s make sure nobody steals it.
4.1 Firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
# Answer y to confirm
sudo ufw status verbose
4.2 Fail2ban (brute-force protection on SSH)
Default config is fine:
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
4.3 Unattended Security Upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
# Select "Yes" when prompted
This applies Ubuntu security patches automatically. You no longer have to think about it.
4.4 Restrict SSH to Key-Only (Optional but Recommended)
sudo nano /etc/ssh/sshd_config.d/50-cloud-init.conf
Make sure these lines are set:
PasswordAuthentication no
PubkeyAuthentication yes
Then:
sudo systemctl restart ssh
Test in a new terminal window (don’t close your current one!) before trusting it.
Part 5: Backups to Azure Blob Storage
This is the part that turns “a VM running n8n” into “a production automation platform.”
5.1 Create an Azure Storage Account
In the Azure portal:
- Search “Storage accounts” → + Create
- Resource group:
n8n-production - Storage account name: something globally unique like
n8nbackupsYOURNAME(must be 3-24 lowercase letters + numbers only) - Region: same as your VM
- Performance: Standard
- Redundancy: LRS (Locally-redundant storage) — cheapest and fine for backups
- Review + Create → Create
5.2 Get the Connection String
- Open the new storage account
- Security + networking → Access keys
- Under key1, click Show next to Connection string
- Copy it. Save it to your password manager.
5.3 Create a Container
- In the storage account, go to Containers (left sidebar under Data storage)
- + Container → Name:
n8n-backups→ Create
5.4 Install Azure CLI on the VM
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
5.5 Add the Connection String to .env
nano ~/n8n/.env
Add at the bottom:
# Azure Blob backup
AZURE_STORAGE_CONNECTION_STRING="PASTE_FULL_CONNECTION_STRING_IN_QUOTES"
AZURE_CONTAINER_NAME=n8n-backups
5.6 Create the Backup Script
nano ~/n8n/scripts/backup.sh
Paste this:
#!/bin/bash
set -e
# Load env vars
export $(grep -v '^#' ~/n8n/.env | xargs)
DATE=$(date +%Y%m%d_%H%M%S)
DAILY_DIR=~/n8n/backups/daily
WEEKLY_DIR=~/n8n/backups/weekly
BACKUP_FILE="n8n_backup_${DATE}.sql.gz"
BACKUP_PATH="${DAILY_DIR}/${BACKUP_FILE}"
mkdir -p "$DAILY_DIR" "$WEEKLY_DIR"
# Dump postgres
docker exec n8n-postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "$BACKUP_PATH"
# Verify backup is non-trivial
SIZE=$(stat -c%s "$BACKUP_PATH")
if [ "$SIZE" -lt 1000 ]; then
echo "ERROR: Backup is suspiciously small ($SIZE bytes). Aborting."
rm -f "$BACKUP_PATH"
exit 1
fi
# Weekly copy on Sundays
if [ "$(date +%u)" = "7" ]; then
cp "$BACKUP_PATH" "${WEEKLY_DIR}/${BACKUP_FILE}"
fi
# Upload to Azure Blob
az storage blob upload \
--container-name "$AZURE_CONTAINER_NAME" \
--file "$BACKUP_PATH" \
--name "daily/${BACKUP_FILE}" \
--connection-string "$AZURE_STORAGE_CONNECTION_STRING" \
--overwrite
# Prune local: keep last 7 daily, last 4 weekly
ls -1t ${DAILY_DIR}/*.sql.gz 2>/dev/null | tail -n +8 | xargs -r rm
ls -1t ${WEEKLY_DIR}/*.sql.gz 2>/dev/null | tail -n +5 | xargs -r rm
echo "Backup complete: ${BACKUP_FILE} (${SIZE} bytes)"
Make it executable:
chmod +x ~/n8n/scripts/backup.sh
Test it:
~/n8n/scripts/backup.sh
You should see “Backup complete” and a file listed in your Azure Blob container.
5.7 Schedule Nightly Backups
crontab -e
Add this line (3 AM Pacific adjusted to UTC… I run 10 AM UTC which is 3 AM Pacific during PDT):
0 10 * * * /bin/bash -lc '/home/azureuser/n8n/scripts/backup.sh >> /home/azureuser/n8n/backups/backup.log 2>&1'
Save and exit. You now have automated backups that:
- Run every night at 3 AM Pacific
- Compress and encrypt with postgres
- Keep 7 daily local copies
- Keep 4 weekly copies (Sundays)
- Upload every backup to Azure Blob (separate storage account, different failure domain)
- Prune old backups automatically
5.8 Restore Test (Do This Once, Now)
I know you don’t want to. Do it anyway. A backup you’ve never tested is not a backup.
# Grab the latest backup
LATEST=$(ls -1t ~/n8n/backups/daily/*.sql.gz | head -n 1)
# Dry-run: just show it's valid gzip + valid SQL
gunzip -c "$LATEST" | head -n 20
You should see postgres dump headers. If the file is valid, you’ll be able to restore from it.
Full restore procedure (for the day you need it):
# Stop n8n
cd ~/n8n && docker compose stop n8n
# Wipe postgres data and restart fresh
docker compose down
sudo rm -rf postgres/*
docker compose up -d n8n-postgres
sleep 10
# Restore
gunzip -c ~/n8n/backups/daily/YOUR_BACKUP.sql.gz | docker exec -i n8n-postgres psql -U n8n -d n8n
# Bring n8n back up
docker compose up -d
Do NOT run this as a test on a production system with real data. Spin up a test VM first if you want to practice.
Part 6: Uptime Monitoring
A monitor you don’t have is a monitor that can’t page you at 2 AM. Get one.
Betterstack has a generous free tier that handles this nicely.
- Sign up at
betterstack.com - Create an HTTP Monitor:
- URL:
https://n8n.yourdomain.com - Check frequency: every 60 seconds (free tier allows this)
- Alert on: non-200 response, SSL issues, response timeout
- URL:
- Add your phone number for SMS alerts
- Add your email for email alerts
If n8n goes down, you’ll know in under a minute. No more “I wonder if that’s still running.”
Part 7: The Shell Script Shortcut
I built a single shell script that handles most of Parts 3-5 automatically. You still need to do Parts 1, 2, 4, and 6 manually (Azure portal clicks, DNS, Betterstack signup), but the Docker install + compose stack + backup setup can run in one go.
curl -fsSL https://n8n-setup.quietlyworking.org/setup.sh -o setup.sh
# Review the script first, because you should always read scripts before running them
less setup.sh
# Run it
bash setup.sh
A note on the URL above: This script will be published alongside this guide. Until then, see the “Appendix: Full Setup Script” section below. Copy it into
setup.shon your VM and run it.
The script will:
- Install Docker + Docker Compose
- Prompt you for your domain and email
- Generate encryption keys and postgres password
- Write
.env,docker-compose.yml, andCaddyfile - Start the stack
- Install Azure CLI
- Prompt you for your Azure Storage connection string
- Write and schedule the backup script
- Run one backup to verify the pipeline end-to-end
If anything fails, the script exits loudly with a clear error. Rerun it after fixing.
Part 8: Claude Desktop As Your Copilot
I use Claude (usually Claude 4.7 in Claude Desktop) as my live copilot for Azure work. When something breaks at 11 PM and I don’t want to think… Claude thinks for me. Here are the prompts I actually use.
Save these in a note file. They’re gold.
Prompt: First-Time Setup Walkthrough
I'm setting up a self-hosted n8n instance on an Azure VM following the
Quietly Working n8n-on-Azure guide. I'm at the step where I need to
[DESCRIBE WHERE YOU ARE].
My setup:
- Azure VM: Ubuntu 22.04, B2s (2 vCPU, 4 GB RAM)
- Domain: n8n.YOURDOMAIN.com
- Using Docker Compose with Caddy + Postgres + n8n
- SSH'd into the VM as azureuser
Walk me through the next step. Tell me what command to run, what output
to expect, and what to do if it fails. Do not assume prior Linux
expertise. Ask before running anything destructive.
Prompt: Troubleshooting a Broken Container
My n8n container is [CRASHING / RESTARTING / REFUSING CONNECTIONS /
SHOWING 502].
Here is the output of:
$ docker compose ps
[PASTE OUTPUT]
$ docker compose logs n8n --tail=100
[PASTE OUTPUT]
$ docker compose logs caddy --tail=50
[PASTE OUTPUT]
Help me diagnose. Ask me for more info if you need it. Walk me through
fixes one at a time. Do not run destructive commands (docker rm, docker
volume rm, rm -rf) without asking first.
Prompt: SSL Certificate Issues
Caddy is [FAILING TO GET A CERT / RETURNING SSL ERRORS / STUCK IN A
REDIRECT LOOP]. Here's the output of:
$ docker compose logs caddy --tail=100
[PASTE]
$ dig +short n8n.YOURDOMAIN.com
[PASTE]
$ curl -vI https://n8n.YOURDOMAIN.com 2>&1 | head -50
[PASTE]
Help me fix this. The domain should be resolving to my VM's public IP.
The VM has ports 80 and 443 open in the Azure NSG. Walk through the
most likely causes in order.
Prompt: Adding a New Environment Variable
I need to add a new environment variable [VAR_NAME=value] to my n8n
instance so I can reference it in workflows as $env.VAR_NAME. Walk me
through editing .env, docker-compose.yml, and restarting the container
safely. My .env is at ~/n8n/.env.
Prompt: Upgrading n8n
My n8n is currently running [VERSION from docker exec n8n n8n --version].
I want to upgrade to the latest. My docker-compose.yml pins the image to
[CURRENT TAG]. Walk me through:
1. Backing up postgres first
2. Changing the image tag safely
3. Pulling and restarting
4. Verifying the upgrade
5. What to do if anything breaks
If there are breaking changes between my version and latest, flag them
and offer a safer intermediate version to jump to first.
Prompt: Backup Restore Dry-Run
I want to verify my nightly backup is actually restorable without
affecting production. Walk me through spinning up a throwaway VM or
local Docker environment, restoring the latest backup from Azure Blob,
and confirming the data is intact. When we're done, help me clean up.
Prompt: Something Weird Just Happened
Something weird is happening with my self-hosted n8n. Here's what I see:
[DESCRIBE WHAT YOU SEE]
I've already tried:
[LIST WHAT YOU TRIED]
Help me diagnose. Ask questions before suggesting fixes. My setup is
Ubuntu 22.04 + Docker Compose + Caddy + Postgres + n8n.
These prompts work because they front-load context. The model doesn’t have to guess your setup. The more specific your prompt, the better the troubleshooting.
Gotchas — The Things I Wish I’d Known
Every one of these cost me hours. Save yourself the pain.
Critical
1. N8N_BLOCK_ENV_ACCESS_IN_NODE=false must be in .env. Since n8n 2.3.x, environment variable access in workflow expressions is blocked by default. Without this setting, roughly 80% of workflows that use $env.VARIABLE will silently fail. This is the single highest-impact setting you will set.
2. Use publish:workflow CLI, not active = true in the database. The old activate model does not properly register schedule triggers. If you’re programmatically managing workflows, always publish via:
docker exec n8n n8n publish:workflow --id=<id>
3. If/Switch v2 nodes silently always return true if you forget "combinator": "and". Every conditions object needs the combinator. Otherwise your branching logic routes every item to output 0, regardless of conditions.
4. Don’t use the native Supabase node (v1). It generates malformed PostgREST URLs (id..value with double dots instead of id.eq.value). Use the HTTP Request node with direct PostgREST calls instead. You’re not losing much… the native node is mostly a convenience wrapper anyway.
5. SSH nodes require "authentication": "privateKey" explicitly. Without it, the node defaults to password auth and fails silently with “no credentials set” errors, even when you have SSH key credentials attached.
High Impact
6. Webhook nodes need UUID-style node IDs. If you import a workflow with a webhook node that has a non-UUID ID, the webhook registration fails silently. n8n returns 404s for what looks like a published workflow. Use uuid.uuid4() format.
7. Published versions are immutable in the workflow_history table. If you update a workflow and publish, the old version is preserved. If something’s not behaving the way you expect, check that you’re running the version you think you’re running. List with docker exec n8n n8n list:workflow.
8. Shell-special characters in SSH node arguments get corrupted. If you pass complex JSON payloads as SSH node arguments, base64-encode them first. Decode on the receiving end. Otherwise quotes, pipes, and ampersands will turn into gibberish.
9. Postgres is the right choice over SQLite. SQLite is fine for development. In production, it’s a foot-gun: file locking during high-throughput writes, backup complexity, no hot-restore. Postgres is three extra lines of docker-compose. Worth it.
10. Bind n8n to 127.0.0.1:5678, never 0.0.0.0:5678. Caddy routes traffic to n8n via the Docker network. If you expose 5678 on the public interface, you’ve bypassed Caddy (and its HTTPS) and exposed an unencrypted n8n to the internet.
Medium Impact
11. Set GENERIC_TIMEZONE=America/Los_Angeles (or yours). Without this, workflow schedules run in UTC. You will schedule a workflow for “9 AM” and it will run at 2 AM Pacific. Ask me how I know.
12. Pin your image tag. docker.n8n.io/n8nio/n8n:latest seems convenient until you docker compose pull and a breaking change drops. Pin to a specific version like docker.n8n.io/n8nio/n8n:1.68.0. Upgrade intentionally.
13. Back up .env separately. Your database backup does NOT contain your encryption key. If you lose .env, every encrypted credential in your n8n is bricked. Keep a copy in your password manager.
14. The first cert renewal is ~60 days after issuance. Caddy handles this automatically. But you should know it’s happening so you’re not surprised when you see “obtaining certificate” in logs two months in.
15. docker compose restart does NOT pick up env changes. You need docker compose up -d (which reads .env fresh). This one bites everyone once.
Appendix A: Full Setup Script
Copy this into ~/setup.sh on a fresh VM and run bash setup.sh.
#!/bin/bash
# n8n-on-Azure setup script
# https://quietlyworking.org/self-hosting-n8n-on-azure
set -euo pipefail
echo "=== n8n on Azure Setup ==="
echo ""
read -p "Your domain (e.g., n8n.yourdomain.com): " DOMAIN
read -p "Your email (for SSL cert notifications): " ACME_EMAIL
# --- System packages ---
echo "Installing system packages..."
sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg ufw fail2ban unattended-upgrades
# --- Docker ---
if ! command -v docker >/dev/null 2>&1; then
echo "Installing Docker..."
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
echo "NOTE: log out and back in after this script finishes for docker group membership"
fi
# --- Firewall ---
echo "Configuring UFW..."
sudo ufw --force default deny incoming
sudo ufw --force default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
# --- fail2ban ---
sudo systemctl enable --now fail2ban
# --- Unattended upgrades ---
sudo dpkg-reconfigure -f noninteractive unattended-upgrades || true
# --- n8n directory ---
N8N_DIR="$HOME/n8n"
mkdir -p "$N8N_DIR"/{data,postgres,backups/daily,backups/weekly,scripts}
cd "$N8N_DIR"
# --- Generate secrets ---
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '\n')
# --- .env ---
cat > "$N8N_DIR/.env" <<EOF
# n8n core
N8N_ENCRYPTION_KEY=$N8N_ENCRYPTION_KEY
N8N_HOST=$DOMAIN
N8N_PROTOCOL=https
N8N_PORT=5678
WEBHOOK_URL=https://$DOMAIN/
GENERIC_TIMEZONE=America/Los_Angeles
N8N_BLOCK_ENV_ACCESS_IN_NODE=false
# Postgres
POSTGRES_USER=n8n
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
POSTGRES_DB=n8n
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=n8n-postgres
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=n8n
DB_POSTGRESDB_PASSWORD=$POSTGRES_PASSWORD
# Caddy / SSL
ACME_EMAIL=$ACME_EMAIL
DOMAIN=$DOMAIN
EOF
chmod 600 "$N8N_DIR/.env"
# --- docker-compose.yml ---
cat > "$N8N_DIR/docker-compose.yml" <<'EOF'
services:
n8n-postgres:
image: postgres:16-alpine
container_name: n8n-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
depends_on:
n8n-postgres:
condition: service_healthy
env_file: .env
ports:
- "127.0.0.1:5678:5678"
volumes:
- ./data:/home/node/.n8n
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- n8n
volumes:
caddy_data:
caddy_config:
EOF
# --- Caddyfile ---
cat > "$N8N_DIR/Caddyfile" <<EOF
{\$DOMAIN} {
reverse_proxy n8n:5678
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
EOF
# --- Azure CLI ---
if ! command -v az >/dev/null 2>&1; then
echo "Installing Azure CLI..."
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
fi
# --- Start stack ---
echo ""
echo "Starting Docker Compose stack..."
sg docker -c "cd $N8N_DIR && docker compose up -d"
echo ""
echo "=============================================================="
echo "n8n is starting at https://$DOMAIN"
echo ""
echo "Your encryption key (save this in a password manager):"
echo " $N8N_ENCRYPTION_KEY"
echo ""
echo "Your postgres password (save this in a password manager):"
echo " $POSTGRES_PASSWORD"
echo ""
echo "NEXT STEPS:"
echo " 1. Make sure DNS A record for $DOMAIN points at this VM's IP"
echo " 2. Wait ~60 seconds, then visit https://$DOMAIN"
echo " 3. Create your owner account"
echo " 4. Set up Azure Blob backups (see guide Part 5)"
echo " 5. Set up Betterstack uptime monitoring (see guide Part 6)"
echo "=============================================================="
Appendix B: Backup Script (Standalone)
Save as ~/n8n/scripts/backup.sh, chmod +x, add to cron.
#!/bin/bash
set -e
export $(grep -v '^#' ~/n8n/.env | xargs)
DATE=$(date +%Y%m%d_%H%M%S)
DAILY_DIR=~/n8n/backups/daily
WEEKLY_DIR=~/n8n/backups/weekly
BACKUP_FILE="n8n_backup_${DATE}.sql.gz"
BACKUP_PATH="${DAILY_DIR}/${BACKUP_FILE}"
mkdir -p "$DAILY_DIR" "$WEEKLY_DIR"
docker exec n8n-postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "$BACKUP_PATH"
SIZE=$(stat -c%s "$BACKUP_PATH")
if [ "$SIZE" -lt 1000 ]; then
echo "ERROR: Backup is suspiciously small ($SIZE bytes). Aborting."
rm -f "$BACKUP_PATH"
exit 1
fi
if [ "$(date +%u)" = "7" ]; then
cp "$BACKUP_PATH" "${WEEKLY_DIR}/${BACKUP_FILE}"
fi
if [ -n "${AZURE_STORAGE_CONNECTION_STRING:-}" ]; then
az storage blob upload \
--container-name "${AZURE_CONTAINER_NAME:-n8n-backups}" \
--file "$BACKUP_PATH" \
--name "daily/${BACKUP_FILE}" \
--connection-string "$AZURE_STORAGE_CONNECTION_STRING" \
--overwrite
fi
ls -1t ${DAILY_DIR}/*.sql.gz 2>/dev/null | tail -n +8 | xargs -r rm
ls -1t ${WEEKLY_DIR}/*.sql.gz 2>/dev/null | tail -n +5 | xargs -r rm
echo "Backup complete: ${BACKUP_FILE} (${SIZE} bytes)"
What’s Next
You now have:
- n8n running on infrastructure you control
- Automated nightly backups to a separate storage account
- External uptime monitoring that’ll page you
- Security hardening that satisfies SOC 2 basics
- An AI copilot ready to help you troubleshoot
The real power shows up after week one, when you stop thinking about whether your automations are up. They just are. You build workflows, they run, you sleep.
If you’re stuck on a step, tried all the gotchas, and something’s still weird… reach out. Every question I get asked becomes a new paragraph in the next version of this guide.
Quietly building. 🏗️
See The Whole Ecosystem
Automation is one plane of a bigger system. QWF builds an interconnected family of apps. Quietly Spotting is the hub. Around it orbit Quietly Writing, Quietly Quoting, Quietly Networking, Quietly Knocking, Quietly Tracking, and more. See the live ecosystem map for what’s shipped, what’s building, and how it all connects.
Related Reading
n8n is the execution floor. Above it sits orchestration, below it sits infrastructure, and around it sits the rest of the stack. Start anywhere. Follow the threads.
- How to Give Your AI Agent Superpowers … the three-layer architecture that sits above n8n as the orchestration layer
- The Tool Shed … why n8n earned a Heart-of-4, Child-of-4 QWS score
- Nonprofit Tech Access Guide … the Azure for Nonprofits credit that covers most of this VM’s cost
- How QWF Learns … the discipline behind picking self-hosted over “someone else’s floor”
- QWU Values … why Excellence means protecting your automations from someone else’s data-center hiccup
This guide is maintained by Quietly Working Universe as part of our public transparency project. Quietly Working is a 501(c)(3) nonprofit. All proceeds from our fundraising programs support our charitable mission to serve youth age 30 and younger.
If this saved you time, consider supporting the work at quietlyworking.org.