Open Source
← Back to blog
AutomationJuly 8, 202618 min read

Marketing Automation with n8n: How to Build a Self-Hosted Stack That Replaces $500/mo in SaaS Subscriptions

Quick answer: A single self-hosted n8n instance ($10-20/month VPS) replaces $305-475/month in SaaS subscriptions (Zapier, Make, Hookdeck, Cronitor, Supermetrics). Deploy with Docker in 15 minutes, import production-ready workflow templates for lead routing with scoring, UTM-normalized CRM sync, and weekly Slack performance digests. No per-execution pricing, full data ownership, and Code nodes for custom JavaScript/Python logic that Zapier can't match. The tradeoff: you own uptime, updates, and backups.

The SaaS Automation Tax Is Real — And You're Paying It Every Month

Here's what a typical mid-tier RevOps automation stack looks like in 2026:

ToolWhat It DoesMonthly Cost Zapier (Professional)Multi-step workflows, webhooks$50–100 Make (Pro)Complex branching, data transformation$20–40 Hookdeck or SvixWebhook relay and retry~$20 CronitorCron/uptime monitoring~$15 Supermetrics (lite)Pull ad platform data into sheets/dashboards$100–200 Misc one-trick SaaSEmail validation, URL shortening, PDF generation, etc.~$100

Total: $305–475/mo before overages. And those overages add up — Zapier alone charges $0.03–0.04 per additional task once you cross your tier limit. I've seen accounts hit $300/mo on Zapier alone during high-volume campaign periods.

RankSquire calls this the success tax, and that framing is dead-on. The better your campaigns perform, the more webhooks fire, the more leads flow in, the more tasks execute — and the more you pay. Your growth becomes your penalty.

Now here's the part that should annoy you: all of these tools are doing the same basic things. Moving JSON between APIs. Transforming data. Running on schedules. Sending notifications. You're paying five separate companies to run what amounts to a few HTTP requests and conditional logic.

A single self-hosted n8n instance on a $10–20/mo VPS replaces every tool on that list. Not partially — fully. I've done it, and so have teams that went from $1,400/mo on Zapier to $24/mo on n8n Cloud, as documented in a Ziellab case study. Self-hosted goes even cheaper. One developer on DEV Community reported replacing a €2,000/mo marketing stack with self-hosted n8n workflows for roughly €5/mo in hosting costs (though that figure likely excludes the VPS — budget €10–20/mo for a realistic setup).

Let me show you exactly how.

Why n8n — And Why Self-Hosted

I've managed $50M+ in ad spend across Meta, Google, Bing, and TikTok. I've also built automation pipelines in Python that handle budget pacing, audience refresh, and creative rotation at scale — things Meta's native tools still can't do well. So when I say n8n is the right tool for marketing automation, it's not because I haven't tried alternatives. It's because I have.

Three reasons n8n wins:

  1. No per-operation pricing when self-hosted. Run 10 workflows or 10 million — same cost. This alone makes it the obvious choice for any team scaling past $50k/mo in ad spend.
  2. Code-level control inside a visual builder. Need custom JavaScript to normalize UTM parameters? A Python node for lead scoring logic? Recursive sub-workflows for complex branching? n8n gives you all of that without forcing you into a pure-code paradigm.
  3. Full data ownership. Your lead data, your ad spend numbers, your CRM payloads — none of it routes through a third-party's servers. For teams handling PII or operating under GDPR, this matters.

Now, the self-hosted vs. n8n Cloud question. n8n Cloud starts at $24/mo and is fine if you want managed hosting. But if you're reading this, you're comfortable enough with Docker to save that $24/mo and get more control. Self-hosted means no vendor dependency, no usage caps, and the ability to add custom nodes or modify the source if you need to. n8n uses a fair-code license (sustainable use, not fully open-source) — self-hosted is free for internal business use, just don't resell it as a service.

For a deeper comparison, check my n8n vs Zapier vs Make (2026) breakdown. The short version: Zapier is expensive and limited, Make is cheaper but fragile, n8n self-hosted is the only option that scales without punishing you for growth.

Deploy n8n with Docker in 15 Minutes

I'm assuming you have a VPS with Docker and Docker Compose installed. If you're starting from scratch on DigitalOcean or Hetzner, add 10 minutes for the initial server setup. The 15-minute claim is for the n8n deployment itself — not a full migration.

The docker-compose.yml

Copy this into docker-compose.yml on your server:

version: "3.8"

services:
  n8n:
    image: n8nio/n8n:latest
    container_name: n8n
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - N8N_BASIC_AUTH_ACTIVE=true
      - N8N_BASIC_AUTH_USER=${N8N_USER}
      - N8N_BASIC_AUTH_PASSWORD=${N8N_PASS}
      - N8N_HOST=${N8N_HOST}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://${N8N_HOST}/
      - 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}
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=168
      - GENERIC_TIMEZONE=UTC
n    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      - postgres

  postgres:
    image: postgres:15-alpine
    container_name: n8n_postgres
    restart: unless-stopped
    environment:
      - POSTGRES_DB=${POSTGRES_DB}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  n8n_data:
  postgres_data:

And the corresponding .env file:

N8N_USER=admin
N8N_PASS=change-this-to-a-real-password
N8N_HOST=automation.yourdomain.com
POSTGRES_DB=n8n
POSTGRES_USER=n8n
POSTGRES_PASSWORD=change-this-postgres-password-too

What each piece does

  • PostgreSQL backend — The default SQLite works for testing but will slow down under load. PostgreSQL handles production volume without flinching.
  • Execution pruningEXECUTIONS_DATA_PRUNE=true with MAX_AGE=168 (hours = 7 days) keeps your database from bloating with old execution logs.
  • Persistent volumes — Your workflows and credentials survive container restarts.
  • Basic auth — Quick protection for the editor UI. Put it behind a reverse proxy with HTTPS for production.

HTTPS with Caddy (Optional but Recommended)

Add this to your Compose file if you want automatic HTTPS:

  caddy:
    image: caddy:2
    container_name: n8n_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:

And a Caddyfile:

automation.yourdomain.com {
    reverse_proxy n8n:5678
}

Caddy handles Let's Encrypt certificates automatically. Point your DNS A record to your VPS IP, and you're live with HTTPS in under a minute.

Deploy

docker compose up -d

Visit https://automation.yourdomain.com. You should see the n8n editor. That's it — you're running.

Three Workflows That Replace Your Entire Stack

These aren't theoretical. I've run variations of all three in production. Each one replaces a specific SaaS tool or combination of tools. Import the JSON, adjust the credentials and field mappings, and you're operational.

Workflow 1: Lead Routing with Scoring and Segmentation

Replaces: Zapier multi-step zaps + lead scoring SaaS + basic CRM automation

Incoming lead from a form, ad platform, or webhook → score based on company size, source, and intent signals → route to the correct CRM pipeline with tags.

{
  "name": "Lead Routing with Scoring",
  "nodes": [
    {
      "parameters": {
        "path": "lead-incoming",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "webhook-node",
      "name": "Lead Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [0, 0],
      "webhookId": "lead-incoming"
    },
    {
      "parameters": {
        "jsCode": "const lead = $input.first().json;\nlet score = 0;\n\n// Source scoring\nif (lead.source === 'google_ads') score += 30;\nelse if (lead.source === 'meta_ads') score += 25;\nelse if (lead.source === 'organic') score += 15;\nelse if (lead.source === 'referral') score += 20;\n\n// Company size scoring\nconst employees = parseInt(lead.company_size) || 0;\nif (employees >= 200) score += 30;\nelse if (employees >= 50) score += 20;\nelse if (employees >= 10) score += 10;\n\n// Intent signals\nif (lead.demo_requested === true) score += 20;\nif (lead.pricing_page_views >= 3) score += 15;\n\n// Segment\nlet segment = 'nurture';\nif (score >= 70) segment = 'sales-qualified';\nelse if (score >= 40) segment = 'marketing-qualified';\n\nreturn [{\n  json: {\n    ...lead,\n    lead_score: score,\n    lead_segment: segment,\n    scored_at: new Date().toISOString()\n  }\n}];"
      },
      "id": "scoring-node",
      "name": "Score & Segment",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [220, 0]
    },
    {
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true },
          "conditions": [
            {
              "leftValue": "={{ $json.lead_segment }}",
              "rightValue": "sales-qualified",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        }
      },
      "id": "if-sql-node",
      "name": "Is Sales Qualified?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [440, 0]
    },
    {
      "parameters": {
        "operation": "create",
        "additionalFields": {
          "properties": [
            { "propertyName": "lead_score", "propertyValue": "={{ $json.lead_score }}" },
            { "propertyName": "lead_segment", "propertyValue": "={{ $json.lead_segment }}" },
            { "propertyName": "original_source", "propertyValue": "={{ $json.source }}" }
          ]
        }
      },
      "id": "crm-sql-node",
      "name": "Add to Sales Pipeline (CRM)",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 1,
      "position": [660, -100]
    },
    {
      "parameters": {
        "operation": "create",
        "additionalFields": {
          "properties": [
            { "propertyName": "lead_score", "propertyValue": "={{ $json.lead_score }}" },
            { "propertyName": "lead_segment", "propertyValue": "={{ $json.lead_segment }}" }
          ]
        }
      },
      "id": "crm-nurture-node",
      "name": "Add to Nurture Pipeline (CRM)",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 1,
      "position": [660, 100]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'routed', segment: $json.lead_segment, score: $json.lead_score }) }}"
      },
      "id": "response-node",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [880, 0]
    }
  ],
  "connections": {
    "Lead Webhook": {
      "main": [[{ "node": "Score & Segment", "type": "main", "index": 0 }]]
    },
    "Score & Segment": {
      "main": [[{ "node": "Is Sales Qualified?", "type": "main", "index": 0 }]]
    },
    "Is Sales Qualified?": {
      "main": [
        [{ "node": "Add to Sales Pipeline (CRM)", "type": "main", "index": 0 }],
        [{ "node": "Add to Nurture Pipeline (CRM)", "type": "main", "index": 0 }]
      ]
    }
  },
  "settings": {}
}

The scoring logic lives in a Code node — you can adjust weights, add new signals, or swap to a machine learning model without rebuilding the workflow. Try doing that in Zapier without hitting their 100-step limit.

Workflow 2: UTM-Normalized Webhook → CRM with Geo Enrichment

Replaces: Hookdeck/Svix (webhook relay) + data normalization tool + IP enrichment SaaS

This is the workflow I wish I'd had when I was scaling Meta Ads accounts from $10k/mo to $500k/mo. UTM data comes in messy — mixed casing, missing values, typos in campaign names. Without normalization, your attribution reports are garbage.

{
  "name": "UTM-Normalized Webhook to CRM",
  "nodes": [
    {
      "parameters": {
        "path": "utm-capture",
        "responseMode": "lastNode",
        "options": {}
      },
      "id": "webhook-utm",
      "name": "UTM Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [0, 0],
      "webhookId": "utm-capture"
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\n\nfunction normalize(val, fallback) {\n  if (!val || val === '(not set)' || val === '(none)') return fallback || 'unknown';\n  return val.toString().toLowerCase().trim().replace(/\\s+/g, '-').replace(/[^a-z0-9\\-_]/g, '');\n}\n\nconst normalized = {\n  utm_source: normalize(data.utm_source),\n  utm_medium: normalize(data.utm_medium),\n  utm_campaign: normalize(data.utm_campaign),\n  utm_content: normalize(data.utm_content),\n  utm_term: normalize(data.utm_term),\n  landing_page: data.landing_page || '',\n  referrer: data.referrer || '',\n  ip_address: data.ip_address || '',\n  captured_at: new Date().toISOString()\n};\n\nreturn [{ json: { ...data, ...normalized } }];"
      },
      "id": "normalize-utm",
      "name": "Normalize UTM Params",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [220, 0]
    },
    {
      "parameters": {
        "url": "=https://ipapi.co/{{ $json.ip_address }}/json/",
        "options": {
          "timeout": 5000
        }
      },
      "id": "geo-enrich",
      "name": "Geo Enrichment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [440, 0]
    },
    {
      "parameters": {
        "jsCode": "const lead = $input.first().json;\nconst geo = lead.body || {};\n\nreturn [{\n  json: {\n    email: lead.email || lead.query_email,\n    utm_source: lead.utm_source,\n    utm_medium: lead.utm_medium,\n    utm_campaign: lead.utm_campaign,\n    utm_content: lead.utm_content,\n    utm_term: lead.utm_term,\n    city: geo.city || 'unknown',\n    region: geo.region || 'unknown',\n    country: geo.country_name || 'unknown',\n    captured_at: lead.captured_at\n  }\n}];"
      },
      "id": "merge-geo",
      "name": "Merge Lead + Geo Data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [660, 0]
    },
    {
      "parameters": {
        "operation": "create",
        "additionalFields": {
          "properties": [
            { "propertyName": "utm_source", "propertyValue": "={{ $json.utm_source }}" },
            { "propertyName": "utm_medium", "propertyValue": "={{ $json.utm_medium }}" },
            { "propertyName": "utm_campaign", "propertyValue": "={{ $json.utm_campaign }}" },
            { "propertyName": "utm_content", "propertyValue": "={{ $json.utm_content }}" },
            { "propertyName": "city", "propertyValue": "={{ $json.city }}" },
            { "propertyName": "region", "propertyValue": "={{ $json.region }}" },
            { "propertyName": "country", "propertyValue": "={{ $json.country }}" },
            { "propertyName": "captured_at", "propertyValue": "={{ $json.captured_at }}" }
          ]
        }
      },
      "id": "crm-push",
      "name": "Push to CRM",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 1,
      "position": [880, 0]
    }
  ],
  "connections": {
    "UTM Webhook": {
      "main": [[{ "node": "Normalize UTM Params", "type": "main", "index": 0 }]]
    },
    "Normalize UTM Params": {
      "main": [[{ "node": "Geo Enrichment", "type": "main", "index": 0 }]]
    },
    "Geo Enrichment": {
      "main": [[{ "node": "Merge Lead + Geo Data", "type": "main", "index": 0 }]]
    },
    "Merge Lead + Geo Data": {
      "main": [[{ "node": "Push to CRM", "type": "main", "index": 0 }]]
    }
  },
  "settings": {}
}

The normalization function handles the real-world mess: "Google" vs "google" vs "GOOGLE" all become "google". Empty values and "(not set)" get replaced with "unknown". Special characters get stripped. Your CRM sees clean, consistent data every time.

Geo enrichment uses ipapi.co's free tier (1,000 requests/day). For higher volume, swap to a paid IP geolocation API — it's still cheaper than a dedicated enrichment SaaS.

Workflow 3: Weekly Slack Performance Digest

Replaces: Cronitor (cron monitoring) + Supermetrics (data pulling) + reporting SaaS + manual spreadsheet exports

I migrated reporting from manual spreadsheet exports to a real-time dashboard pulling from 5 ad platforms via API, normalized in Python, and visualized in Looker Studio. That cut reporting time from 4 hours/week to near-zero. This workflow is the Slack companion — a weekly summary that hits your team's channel every Monday at 9am.

{
  "name": "Weekly Slack Performance Digest",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [{ "field": "cronExpression", "expression": "0 9 * * 1" }]
        }
      },
      "id": "schedule-trigger",
      "name": "Every Monday 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.1,
      "position": [0, 0]
    },
    {
      "parameters": {
        "url": "https://googleads.googleapis.com/v17/customers/{{ $env.GOOGLE_ADS_CUSTOMER_ID }}/googleAds:searchStream",
        "method": "POST",
        "options": {}
      },
      "id": "google-ads-data",
      "name": "Pull Google Ads Spend",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [220, -100]
    },
    {
      "parameters": {
        "url": "https://graph.facebook.com/v19.0/{{ $env.META_AD_ACCOUNT_ID }}/insights",
        "options": {}
      },
      "id": "meta-ads-data",
      "name": "Pull Meta Ads Spend",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [220, 100]
    },
    {
      "parameters": {
        "mode": "combine",
        "combinationMode": "mergeByPosition",
        "options": {}
      },
      "id": "merge-data",
      "name": "Merge Ad Data",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [440, 0]
    },
    {
      "parameters": {
        "jsCode": "const google = $input.first().json;\nconst meta = $input.last().json;\n\nconst totalSpend = (parseFloat(google.total_spend) || 0) + (parseFloat(meta.total_spend) || 0);\nconst totalLeads = (parseInt(google.total_leads) || 0) + (parseInt(meta.total_leads) || 0);\nconst totalConversions = (parseInt(google.total_conversions) || 0) + (parseInt(meta.total_conversions) || 0);\nconst blendedCPL = totalLeads > 0 ? (totalSpend / totalLeads).toFixed(2) : 'N/A';\nconst blendedCPA = totalConversions > 0 ? (totalSpend / totalConversions).toFixed(2) : 'N/A';\n\nconst text = `📊 *Weekly Performance Digest*\n\n` +\n  `*Total Spend:* ${totalSpend.toFixed(2)}\n` +\n  `*Total Leads:* ${totalLeads}\n` +\n  `*Total Conversions:* ${totalConversions}\n` +\n  `*Blended CPL:* ${blendedCPL}\n` +\n  `*Blended CPA:* ${blendedCPA}\n\n` +\n  `*Google Ads:* ${(parseFloat(google.total_spend) || 0).toFixed(2)} spend | ${google.total_leads || 0} leads\n` +\n  `*Meta Ads:* ${(parseFloat(meta.total_spend) || 0).toFixed(2)} spend | ${meta.total_leads || 0} leads`;\n\nreturn [{ json: { slackText: text } }];"
      },
      "id": "format-digest",
      "name": "Format Digest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [660, 0]
    },
    {
      "parameters": {
        "resource": "message",
        "channel": "#marketing-performance",
        "text": "={{ $json.slackText }}",
        "otherOptions": {}
      },
      "id": "slack-post",
      "name": "Post to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2,
      "position": [880, 0]
    }
  ],
  "connections": {
    "Every Monday 9am": {
      "main": [
        [
          { "node": "Pull Google Ads Spend", "type": "main", "index": 0 },
          { "node": "Pull Meta Ads Spend", "type": "main", "index": 0 }
        ]
      ]
    },
    "Pull Google Ads Spend": {
      "main": [[{ "node": "Merge Ad Data", "type": "main", "index": 0 }]]
    },
    "Pull Meta Ads Spend": {
      "main": [[{ "node": "Merge Ad Data", "type": "main", "index": 1 }]]
    },
    "Merge Ad Data": {
      "main": [[{ "node": "Format Digest", "type": "main", "index": 0 }]]
    },
    "Format Digest": {
      "main": [[{ "node": "Post to Slack", "type": "main", "index": 0 }]]
    }
  },
  "settings": {}
}

The HTTP Request nodes for Google Ads and Meta Ads need authentication — set up credentials in n8n's credential store. The Google Ads node uses the Google Ads API directly; the Meta node uses the Marketing API. Both require API access on those platforms, which you should already have if you're running any meaningful ad spend.

For deeper control over your Meta Ads automation setup, see my Meta Ads MCP guide — it covers what's safe to automate and what needs human oversight.

What You're Giving Up (And Why It Doesn't Matter)

Self-hosting isn't all upside. Let me be honest about the trade-offs:

No managed support

Zapier has a support team. n8n self-hosted has a community forum and Discord. In practice, I've gotten faster answers from the n8n community than from Zapier's support queue. But if you need a guaranteed SLA for issue resolution, this is a real gap. Counter: how often do you actually file support tickets for automation tools? For most teams, community support is sufficient.

You own your backups

If your VPS catches fire and you don't have backups, your workflows are gone. This is not theoretical — I've seen people lose everything because they skipped this step. Set up a cron job that dumps the PostgreSQL database nightly and uploads it to S3 or Backblaze B2. It's 10 lines of bash and costs pennies per month in storage.

#!/bin/bash
# Add to crontab: 0 2 * * * /path/to/backup-n8n.sh

DATE=$(date +%Y-%m-%d)
PGPASSWORD="$POSTGRES_PASSWORD" pg_dump \
  -h localhost -U n8n -d n8n \
  | gzip > /tmp/n8n-backup-$DATE.sql.gz

aws s3 cp /tmp/n8n-backup-$DATE.sql.gz \
  s3://your-backup-bucket/n8n/$DATE.sql.gz

rm /tmp/n8n-backup-$DATE.sql.gz

The UI is less polished

Zapier's editor is smoother. Make's visual flow is prettier. n8n's UI works fine but has rough edges — some node configuration panels are inconsistent, error messages can be cryptic, and the search function occasionally misses installed nodes. If you need pixel-perfect UX to be productive, you'll notice. If you care about getting things done, you won't care.

Updates are your responsibility

docker compose pull && docker compose up -d handles most updates. But you need to actually run it, and occasionally check the changelog for breaking changes. Budget 15 minutes per month for this. If even that feels like too much, consider n8n Cloud instead — but then you're back to paying monthly.

The Real Cost Comparison

Let's do the actual math. I'm using current pricing as of mid-2026 — verify these numbers before you cite them, as SaaS pricing changes frequently.

ItemSaaS Stack (Monthly)Self-Hosted n8n (Monthly) Automation platform$50–100 (Zapier Professional)$0 (self-hosted) Backup automation$20–40 (Make Pro)$0 (included) Webhook relay~$20 (Hookdeck)$0 (n8n webhooks) Cron monitoring~$15 (Cronitor)$0 (n8n scheduled triggers) Data pulling / ETL$100–200 (Supermetrics)$0 (HTTP Request nodes) Misc one-trick tools~$100$0 (Code nodes) VPS hosting$0$10–20 Your maintenance time$0~1 hr/mo (opportunity cost) Total$305–475/mo$10–20/mo

Annual savings: $3,420–5,460/yr

Even if you value your maintenance time at $150/hr (a reasonable rate for a senior marketing ops person), that's $150/mo in opportunity cost — bringing the true self-hosted cost to $160–170/mo. Still less than half the SaaS stack. And the gap only widens as your automation volume grows, because the SaaS stack starts charging overages while n8n's cost stays flat.

For teams running $100k+/mo in ad spend, this isn't a marginal saving — it's a meaningful reduction in operational overhead that scales with your growth instead of penalizing it. I've written more about this decision framework in my Build vs Buy vs Automate post.

Getting Started: The 3-Step Migration

  1. Deploy n8n. Copy the Docker Compose file above. Run docker compose up -d. You're live in 15 minutes.
  2. Import the workflows. Copy each JSON above into n8n's import dialog (Workflows → Import from File). Update credentials and field mappings to match your CRM and ad accounts.
  3. Cancel your SaaS subscriptions. Not immediately — run n8n in parallel for a week, verify the data matches, then cut the cord.

That's the migration. Not a weekend project. Not a quarter-long initiative. A week of parallel running, then you're done.

If you want more workflow templates like these — lead scoring variations, multi-touch attribution pipelines, creative rotation automation — I send them out in my newsletter every week along with detailed breakdowns of how I'm automating marketing operations.

Get my n8n workflow templates + automation breakdowns in your inbox every week.

Frequently Asked Questions

  • How much does self-hosted n8n actually cost per month?

    A VPS capable of running n8n with PostgreSQL costs $10–20/mo on providers like DigitalOcean or Hetzner. That's your only hard cost. There are no per-operation fees, no task limits, and no overage charges. The trade-off is your time for maintenance — budget roughly 1 hour per month for updates and monitoring.

  • Can n8n really replace Zapier for marketing automation?

    Yes, for any team comfortable with basic Docker and API configuration. n8n handles multi-step workflows, webhooks, scheduled triggers, conditional branching, and data transformation — everything Zapier does. The key difference is that n8n also gives you Code nodes (JavaScript and Python) for complex logic that would require multiple paid Zapier steps or custom apps. Where n8n falls short is polish and integrations — Zapier has more pre-built app connectors, though n8n's list is growing fast.

  • What happens if my self-hosted n8n goes down?

    Your automations stop running until the server comes back. This is the real trade-off of self-hosting. Mitigate it with: restart policies in Docker Compose (already in the config above), uptime monitoring via a free service like UptimeRobot pointing to your n8n health endpoint, and the PostgreSQL backup script included in the article. For mission-critical workflows, consider a secondary n8n instance on a different VPS as a warm standby.

  • Is it hard to migrate from Zapier to n8n?

    The technical migration is straightforward — most Zapier workflows map to n8n workflows with equivalent nodes. The time investment depends on how many zaps you have and how complex they are. A realistic timeline: 1–2 days to rebuild 10–15 workflows, then a week of parallel running to verify data consistency. The Ziellab case study reports a weekend migration from $1,400/mo in Zapier workflows to n8n.

  • Do I need to be a developer to self-host n8n?

    You need to be comfortable with Docker, the command line, and basic server administration. You don't need to be a software developer. If you can follow a Docker Compose file, SSH into a server, and edit environment variables, you can self-host n8n. If that sounds intimidating, n8n Cloud at $24/mo is a reasonable middle ground — still cheaper than Zapier, with managed infrastructure.

Related reading: Google Performance Max: How to Actually Control the AI Black Box

Related reading: AI Ad Creative at Scale: Generate Hundreds of Variations With Midjourney, DALL-E, and Runway

Related reading: AI Email Personalization Beyond Hi First Name: Real 1:1 Personalization at Scale

References

#n8n#Zapier alternatives#self-hosted automation#RevOps#marketing automation#Docker#lead routing#workflow templates

Comments (0)

Loading comments...