Every small business owner has the same inbox: real leads buried under supplier newsletters, “is this still available?” questions, invoices and spam. One missed lead can cost more than a year of software. Zapier’s answer is a monthly plan that gets expensive once AI steps start eating tasks. n8n’s answer is: run it yourself, for about the price of a coffee.

In this guide you’ll self-host n8n on a cheap VPS, build a workflow that reads incoming Gmail, has an AI sort each message into lead / support / invoice / noise, logs leads to Google Sheets, pings you on Telegram and drafts a reply. At the end we’ll look at what this costs to run and what you can realistically charge a local business to set it up.

Build time: 2–3 hours the first time, about 45 minutes once you’ve done it before.

What you’ll build

  • n8n running in Docker with automatic HTTPS (Caddy + Let’s Encrypt)
  • A Gmail trigger that checks for new mail every minute
  • An OpenAI step that classifies each email and returns structured JSON
  • Routing: leads go to a Google Sheet plus a Telegram alert, support emails get an AI-drafted reply saved as a Gmail draft (never auto-sent), and noise gets labelled and archived
  • A nightly backup of your workflows

What you need

ItemCostNotes
VPS, 2 vCPU / 4 GB RAM~€4–6/monthHetzner CX22 / CX23 class, or a $6 DigitalOcean / Vultr droplet (1 GB, add swap)
Domain or subdomain$0 if you already own oneYou need it for HTTPS and Google OAuth
n8n Community Edition$0Self-hosted, no execution limits
OpenAI API keyPay-as-you-gogpt-4o-mini: $0.15 per 1M input tokens, $0.60 per 1M output
Google Cloud project$0For Gmail and Sheets OAuth
Telegram bot$0Created with @BotFather

Prices are as of writing. Check the provider pages before you commit, because VPS prices in particular have moved around in the last year.

Why not n8n Cloud? It’s a legitimate option. The Starter plan is about €24/month (€20 billed annually) for 2,500 executions. Empty polls don’t count as executions, so 1,000 emails a month fits under the cap, but a busier inbox or a few extra workflows won’t, and you’re paying 4–5x the VPS cost either way. Use Cloud if you never want to touch a terminal. Self-host if you’re fine with 20 minutes of maintenance a month.

Step 1: Prepare the server

Spin up an Ubuntu 24.04 server and point a DNS A record (e.g. n8n.yourdomain.com) at its IP. Then:

ssh root@YOUR_SERVER_IP

apt update && apt upgrade -y
apt install -y ca-certificates curl ufw

# Firewall: SSH + web only
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable

# Docker (official convenience script)
curl -fsSL https://get.docker.com | sh

# 2 GB swap: important on 1-2 GB RAM boxes
fallocate -l 2G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

Step 2: Run n8n behind Caddy

mkdir -p /opt/n8n && cd /opt/n8n
openssl rand -hex 32   # copy this output: it's your encryption key

Create docker-compose.yml:

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    restart: unless-stopped
    environment:
      - N8N_HOST=n8n.yourdomain.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.yourdomain.com/
      - N8N_ENCRYPTION_KEY=PASTE_THE_KEY_HERE
      - GENERIC_TIMEZONE=Asia/Jakarta
      - TZ=Asia/Jakarta
      - N8N_RUNNERS_ENABLED=true
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=168
    volumes:
      - n8n_data:/home/node/.n8n
      - ./backups:/backups

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data

volumes:
  n8n_data:
  caddy_data:

Create Caddyfile:

n8n.yourdomain.com {
    reverse_proxy n8n:5678
}

Start it:

mkdir -p backups && chown 1000:1000 backups
docker compose up -d
docker compose logs -f n8n   # wait for "Editor is now accessible"

Open https://n8n.yourdomain.com and create the owner account. Caddy gets the TLS certificate automatically within about 30 seconds.

Save the encryption key somewhere outside the server, for example in a password manager. If you lose it, every stored credential (Gmail, OpenAI, Sheets) becomes unreadable after a restore.

EXECUTIONS_DATA_MAX_AGE=168 keeps 7 days of execution logs. Recent n8n versions already prune by default (14 days). Setting it explicitly halves that and makes sure a busy workflow doesn’t slowly fill the SQLite database on a small disk.

Step 3: Connect Gmail and Google Sheets

This is where most people give up, so go slowly.

  1. Go to console.cloud.google.com and create a project.
  2. APIs & Services → Library: enable Gmail API and Google Sheets API.
  3. OAuth consent screen (now called Google Auth Platform → Branding / Audience in newer consoles): choose External, add your own email as a test user. Under Data Access, add the Gmail and Sheets scopes.
  4. Credentials → Create OAuth client ID → Web application.
  5. Authorized redirect URI: https://n8n.yourdomain.com/rest/oauth2-credential/callback
  6. In n8n, go to Credentials → New → Gmail OAuth2 API, paste the Client ID and Secret, and click Sign in with Google.

Repeat step 6 for Google Sheets OAuth2 API using the same client.

While you’re setting up credentials:

  • Google Sheet: create a sheet with a header row of date | from | subject | summary | urgency.
  • Telegram: create a bot with @BotFather, add a Telegram API credential in n8n with the bot token, send your bot any message, then open https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates in a browser. The chat.id value shown there is your chat ID.
  • OpenAI: add an OpenAI API credential with your API key. Your account needs a payment method or prepaid credit.

Step 4: Build the workflow

Create a new workflow and add the nodes in this order.

Node 1: Gmail Trigger

  • Poll Times: Every Minute
  • Event: Message Received
  • Simplify: on
  • Filters → Search: -category:promotions -category:social

That one search filter drops a big share of junk before it costs you a single token.

Note: with Simplify on, the output contains From, Subject and a ~200-character snippet, but not the full body. To get the full text ($json.text) you’ll later turn Simplify off. The field names then change to lowercase ($json.from.text, $json.subject).

Node 2: OpenAI (Message a Model)

  • Model: gpt-4o-mini (or gpt-5-nano if you want it even cheaper)
  • Option: Output Content as JSON on (the exact label varies slightly between node versions; the prompt must contain the word “JSON”, which ours does)
  • System message:
You triage email for a small business that sells {{ describe the business }}.
Classify the email into exactly one category:
- "lead": a potential customer asking about price, availability, quotes, booking
- "support": an existing customer with a question or problem
- "invoice": bills, payment confirmations, receipts
- "noise": newsletters, notifications, cold sales pitches, spam

Return JSON only:
{"category": "...", "urgency": 1-5, "summary": "one sentence",
 "customer_name": "... or null", "draft_reply": "short polite reply or null"}

Write draft_reply only for "lead" and "support", in the same language as the email.
Never invent prices, dates or promises. If unsure, ask a clarifying question.
  • User message:
From: {{ $json.From }}
Subject: {{ $json.Subject }}
Body: {{ $json.snippet }}

Use snippet (the first ~200 characters) for testing. Once it works, turn Simplify off in the trigger and change the user message to:

From: {{ $json.from.text }}
Subject: {{ $json.subject }}
Body: {{ ($json.text || '').slice(0, 3000) }}

Node 3: Code (parse and merge)

// Parse the model output safely and keep the original email data
// Mode: Run Once for All Items
const out = [];
const items = $input.all();
for (let i = 0; i < items.length; i++) {
  const j = items[i].json;
  let parsed;
  try {
    // Output shape differs between OpenAI node versions; check the
    // node's output panel and adjust this path if needed.
    const raw = j.message?.content ?? j.output?.[0]?.content?.[0]?.text
             ?? j.content ?? j.text ?? '{}';
    parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
  } catch (e) {
    parsed = {};
  }
  const valid = ['lead', 'support', 'invoice', 'noise'];
  if (!valid.includes(parsed.category)) {
    parsed = { category: 'support', urgency: 3,
               summary: 'AI parse failed - check manually', draft_reply: null };
  }
  const email = $('Gmail Trigger').itemMatching(i).json;
  out.push({ json: {
    ...parsed,
    // Works with Simplify on (From/Subject) or off (from.text/subject)
    from: email.From ?? email.from?.text,
    replyTo: email.from?.value?.[0]?.address ?? email.From,
    subject: email.Subject ?? email.subject,
    threadId: email.threadId,
    messageId: email.id,
  }});
}
return out;

If parsing fails or the model returns an unknown category, the email defaults to “support”. That’s deliberate: it lands in front of a human instead of being silently archived.

Node 4: Switch

Route on {{ $json.category }} with four outputs: lead, support, invoice, noise.

Lead branch

  • Google Sheets → Append Row with columns: date ({{ $now.toISO() }}), from, subject, summary, urgency
  • Telegram → Send Message to your chat ID:
🔥 New lead (urgency {{ $json.urgency }}/5)
From: {{ $json.from }}
{{ $json.summary }}
  • Gmail → Draft → Create: Subject Re: {{ $json.subject }}, Message {{ $json.draft_reply }}, and under Options set Thread ID = {{ $json.threadId }} and To Email = {{ $json.replyTo }} so the draft appears in the conversation with the recipient filled in. (replyTo is the bare address when Simplify is off. With Simplify on it’s the Name <email> string, which Gmail usually accepts.)

Support branch

The same Gmail Create Draft node, nothing else. You review and send it yourself.

Invoice and noise branches

Gmail → Message → Add Label (AI/Invoice or AI/Noise), using Message ID {{ $json.messageId }}. For noise, add a Message → Remove Label node with INBOX to archive it. Create the labels in Gmail first.

Activate the workflow, then send yourself three test emails: a fake price inquiry, a complaint and a newsletter. Check that each one lands in the right place.

Step 5: Nightly backups

Run crontab -e and add:

0 2 * * * cd /opt/n8n && docker compose exec -T n8n n8n export:workflow --all --separate --output=/backups/ && find /opt/n8n/backups -type f -mtime +14 -delete

That exports every workflow as JSON each night. Credentials aren’t included. They live encrypted in the n8n_data volume, which is one more reason to keep the encryption key safe. Sync /opt/n8n/backups to somewhere off-server (rclone to Google Drive or Backblaze B2 costs pennies) and you can rebuild everything in 10 minutes.

Cost breakdown: what this actually costs to run

Assume a small business gets 1,000 emails a month that pass the filter. Each AI call is about 800 input tokens (prompt plus email) and 150 output tokens.

ItemMonthly
VPS~$5–6
OpenAI input: 0.8M tokens × $0.15$0.12
OpenAI output: 0.15M tokens × $0.60$0.09
Google, Telegram, n8n$0
Total~$5.50–6.50

The AI part is about 20 cents. The server is 95% of the bill. You can install one instance per client on their own account, or run several of your own workflows on one server.

For comparison, on Zapier every action step counts as a task (triggers and filters don’t). That’s roughly 2–4 tasks per email depending on the branch, so 1,000 emails means about 2,000–4,000 tasks a month. That lands in the mid tiers of the Professional plan, typically somewhere around $50–100/month depending on tier and billing period. Check Zapier’s pricing page for current numbers. That price gap is your sales pitch.

Turning it into money

There are three realistic ways to make money from this, ordered from most to least reliable. None of the numbers below are guaranteed. They’re reasonable starting points, not anyone’s proven income.

1. Setup-as-a-service for local businesses. Clinics, property agents, wedding vendors, contractors, and anyone else whose leads arrive by email. A reasonable starting price for this exact workflow (adjust to your local market):

  • Setup: $150–400 (Rp 2.5–6.5 million) depending on market
  • Maintenance: $25–50/month (watching for errors, updating prompts, re-auth when something breaks)

Your time: 3–5 hours per client including the discovery call and tweaking the prompt to fit their business. For example, if you land three maintenance clients at $40, that’s $120/month recurring, and each client pays for their own ~$6 server. Landing them is the hard part.

2. Freelance platforms. Posted rates for “n8n” gigs on Upwork and Fiverr commonly fall in the $25–75/hour range. Competition is real and new profiles struggle at first. Expect to underprice your first 2–3 jobs to get reviews.

3. Affiliate / content. n8n runs an affiliate program for its Cloud plans, and hosting providers like Hetzner, DigitalOcean and Vultr offer referral credits. This works if you already produce tutorials. On its own it’s slow money: think tens of dollars a month for the first several months, not hundreds.

Realistic expectations: a first client can take several weeks to a few months of actively pitching local businesses, with a working demo on your own inbox to show them. It may not happen at all. What’s close to certain is zero clients if you build it and wait for people to find you.

Reality check: what does NOT work

Auto-sending AI replies. Don’t. gpt-4o-mini will occasionally invent a price, a delivery date or a discount. One confident wrong answer to a customer erases all the time you saved. Drafts only, with a human clicking send. Explain this to clients as a feature, not a limitation.

Leaving Google OAuth in “Testing” mode. For external apps in testing status, Google refresh tokens for Gmail scopes expire after 7 days. Your workflow will silently stop next week. Either push the app to production (for your own internal use you can accept the “unverified app” warning), or use a Google Workspace account with an Internal app. This is the #1 reason “my n8n stopped working” posts exist.

A 1 GB VPS without swap. n8n plus task runners can spike past 1 GB during heavy executions. The container gets OOM-killed and you won’t notice until a client asks why leads stopped. Use 2 GB+ or add the swap from Step 1.

Sending the full email body uncapped. Long email threads with quoted history can run 10,000+ tokens. Cap the body at roughly 3,000 characters in an expression ({{ ($json.text || '').slice(0, 3000) }}, with Simplify off) or your $0.20 bill grows several-fold, to a dollar or two a month on gpt-4o-mini. That’s still small, but it’s sloppy, and it adds up across clients.

Running :latest blindly on client servers. n8n ships fast and occasionally changes node behaviour. For clients, pin a specific version tag, test upgrades on your own instance first, then roll them out.

Ignoring the license. n8n uses the Sustainable Use License. Building and maintaining workflows for clients, as consulting or on their own servers, is fine. Reselling access to your n8n instance as a hosted product is not. If you want to do that, read the license or talk to n8n about an embed agreement.

Selling “AI automation” instead of an outcome. Business owners don’t buy n8n. They buy “you’ll never miss a quote request again, and you’ll see every lead in one sheet.” Lead with the missed-revenue story, show the Telegram alert live on your phone, and only then mention the tech.

Final checklist

  • VPS up, DNS pointing, HTTPS working
  • Encryption key stored off-server
  • Google OAuth app not stuck in Testing
  • Gmail search filter excludes promotions and social
  • Body length capped before the AI node
  • Replies saved as drafts, never auto-sent
  • Nightly workflow export plus off-site sync
  • Execution pruning enabled
  • Error Workflow set (Settings → Error Workflow → Telegram alert) so failures reach you

Build it for your own inbox first. Run it for two weeks. Once it has caught a lead you would have missed, you have both a working product and a real story to sell it with.