n8n: Workflow Automation Without Zapier's Invoice

The same "if this happens, do that" automation, minus the per-task metering

Contents

Zapier bills by the “task” — every single action a workflow performs, counted and metered like an API you’re renting by the call. Run a workflow that checks a feed, filters it, and posts to three places, and you’ve spent four tasks before you’ve automated anything more interesting than a Tuesday. I hit the ceiling on a free Zapier plan doing something genuinely trivial — mirroring form submissions into a spreadsheet and a Discord webhook — and went looking for the self-hosted equivalent. n8n is that equivalent, and it has been running unattended on a small VM for over a year doing the sort of glue work that used to mean writing a cron job and a Python script every time I wanted two services to talk to each other.

n8n (pronounced “n-eight-n,” short for “nodemation”) is a workflow automation platform: a visual canvas where you drag “nodes” — Slack, HTTP Request, Postgres, RSS, Cron, hundreds more — and wire them together into a pipeline. It’s the same category as Zapier, Make (formerly Integromat), and IFTTT, but it’s self-hosted, source-available under n8n’s “Sustainable Use License,” and — the actual point — has no per-execution billing when you run it yourself. You pay in server resources you already own, regardless of how many tasks a workflow runs.

Why the node model beats a script

Advertisement

I could write everything n8n does as shell scripts and cron jobs. I did, for years, before switching. The reason n8n won isn’t that it’s more powerful than a script — a script is more powerful, full stop, it can do anything — it’s that a workflow with fifteen scripts chained together via cron and file-watching becomes unreadable within a month, and debugging it means grepping logs across five files to reconstruct what happened. n8n’s canvas keeps the whole pipeline visible in one screen: you can see the shape of the data flowing between nodes, click any past execution and see exactly what payload each node received and produced, and re-run a single failed node without re-triggering the whole chain. That execution history is the feature that actually saves time — when something breaks at 3am, I open the failed execution, see the exact JSON that broke a downstream node, and fix it in the time it would have taken to just find the right log file.

The trigger model is also genuinely well designed. Workflows start from a trigger node — a webhook, a cron schedule, a polling trigger on an RSS feed or an email inbox, or a manual button — and everything downstream only runs when that trigger fires. That’s the same event-driven shape as Zapier, and it’s the right shape for most home-automation and small-business glue work: react to something happening, rather than a script polling in a loop wondering if anything changed.

There’s a second, quieter advantage over ad-hoc scripting: n8n keeps every credential in one encrypted store, referenced by name from any node that needs it, instead of scattered across a dozen scripts’ environment variables or, worse, hardcoded inline because it was faster at the time. When I rotate an API key, I update it once in n8n’s credential manager and every workflow using that credential picks up the new value on its next run — no grepping through old scripts to find every place a token got pasted in, which used to be exactly the kind of cleanup I’d put off until it caused a problem.

Deploying it

n8n needs a database for workflow and execution storage — SQLite works for a single low-traffic instance, but Postgres is the documented production path and the only sane choice once you’re running more than a handful of workflows, because SQLite’s single-writer lock becomes a real bottleneck under concurrent webhook triggers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# docker-compose.yml
services:
  n8n:
    image: n8nio/n8n:latest
    container_name: n8n
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - N8N_HOST=n8n.example.com
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.example.com/
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=db
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD}
      - N8N_BASIC_AUTH_ACTIVE=true
      - N8N_BASIC_AUTH_USER=admin
      - N8N_BASIC_AUTH_PASSWORD=${N8N_ADMIN_PASSWORD}
      - GENERIC_TIMEZONE=Europe/Copenhagen
    volumes:
      - n8n-data:/home/node/.n8n
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: n8n-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: ${N8N_DB_PASSWORD}
      POSTGRES_DB: n8n
    volumes:
      - n8n-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "n8n"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  n8n-data:
  n8n-db-data:

N8N_ENCRYPTION_KEY deserves a callout: n8n uses it to encrypt stored credentials (API keys, OAuth tokens, database passwords you’ve wired into nodes) at rest in the database. Generate it once with openssl rand -hex 24, and back it up somewhere separate from the database backup — lose the key without the database and your workflows survive but every stored credential becomes permanently unreadable, forcing you to re-enter every integration’s auth from scratch.

Put it behind a reverse proxy for TLS — see Reverse Proxy Done Right — and treat N8N_BASIC_AUTH_ACTIVE as a bare minimum. It’s HTTP basic auth over what should already be a TLS-terminated connection, useful mostly as a first speed bump; for anything with real credentials wired in, put it behind Tailscale rather than exposing the editor UI to the open internet at all.

A concrete workflow

Advertisement

The one that justified the whole setup for me: a webhook receives a form submission from a static contact form on a website with no backend, validates the payload, writes a row to a Postgres table, and posts a formatted message to a Discord channel — with a retry-on-failure branch if the Discord webhook is down.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "trigger": "Webhook (POST /contact)",
  "nodes": [
    { "name": "Validate", "type": "n8n-nodes-base.if",
      "condition": "json.email is not empty AND json.message.length > 10" },
    { "name": "Insert to Postgres", "type": "n8n-nodes-base.postgres",
      "operation": "insert", "table": "contact_submissions" },
    { "name": "Notify Discord", "type": "n8n-nodes-base.discord",
      "webhookUrl": "={{$env.DISCORD_WEBHOOK_URL}}",
      "onError": "continueErrorOutput" },
    { "name": "Log Failure", "type": "n8n-nodes-base.postgres",
      "operation": "insert", "table": "notification_failures" }
  ]
}

That’s a simplified sketch of the node graph rather than n8n’s actual export format, but it captures the shape: the If node branches on validation, the Postgres insert is the durable record regardless of what happens next, and the Discord node’s continueErrorOutput setting means a downed webhook doesn’t lose the submission — it just logs the failure to a separate table I can requeue manually. That last part is the thing a bare curl in a script would silently drop.

Webhook security

A publicly reachable /webhook/* endpoint is n8n’s most common attack surface, because triggers are, by design, URLs anyone can POST to unless you lock them down. Three things I do on every webhook-triggered workflow:

  • Set a webhook authentication method on the trigger node itself — n8n supports header auth (a shared secret in a custom header), basic auth, or JWT verification, each configurable independently on every individual webhook.
  • Validate the payload shape before doing anything with it. The example above does this with an If node before the database write — never trust that a POST to a public URL came from the form you expect.
  • Use production webhook URLs for anything long-lived. n8n gives every webhook node a “test” URL (only active while the editor is open and listening) and a “production” URL (always active once the workflow is activated). Building against the test URL and forgetting to switch is the single most common “why did my automation stop working” support question in the n8n community forum.

Queue mode

A single n8n container runs everything — UI, webhook listener, and workflow execution — in one process, which is fine until a slow workflow (a large file processing step, a slow third-party API) blocks the event loop for everything else, including the webhook listener for unrelated workflows. Queue mode splits this apart: n8n adds a Redis-backed queue, a dedicated “main” instance that handles the UI and webhook reception, and one or more separate worker processes that actually execute workflows pulled off the queue.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  n8n-worker:
    image: n8nio/n8n:latest
    container_name: n8n-worker
    restart: unless-stopped
    command: worker
    environment:
      - EXECUTIONS_MODE=queue
      - QUEUE_BULL_REDIS_HOST=redis
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=db
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD}
    depends_on:
      - db
      - redis

I only turned this on once I had workflows that genuinely took minutes (a nightly export-and-transform job), because for the sub-second webhook glue work that makes up most of my automations, single-instance mode never showed a symptom worth the added Redis dependency. Queue mode earns its keep once something’s actually slow, and stays overhead until then.

Error workflows and monitoring

Advertisement

Every n8n workflow can be assigned an error workflow — a separate workflow that fires automatically whenever the parent fails, receiving the error details as its trigger payload. I route every error workflow in my instance to a single “Notify me something broke” workflow that posts to a private Discord channel with the failed workflow’s name, the node that threw, and a link straight into that specific failed execution. Without this, the honest failure mode of self-hosted automation is that things silently stop working and you find out weeks later when someone asks why the contact form stopped notifying anyone — the same blind spot that makes unmonitored cron jobs a liability.

Execution history itself is retained according to EXECUTIONS_DATA_MAX_AGE (defaults to two weeks); for an instance handling anything with even mild compliance relevance, decide deliberately how long you want that history kept rather than accepting the default, and prune it via the EXECUTIONS_DATA_PRUNE settings so the Postgres database doesn’t grow unbounded on a busy instance.

The Code node, and when to reach for it

Every node covers a specific integration, but sooner or later a workflow needs a transformation none of the built-in nodes quite express — reshaping a nested JSON payload, computing something with actual logic, or calling a library that doesn’t have a dedicated node. The Code node drops you into JavaScript (or Python, via a slower Pyodide-based runtime) with the current item’s data available directly, and it’s the escape hatch that keeps n8n from becoming a maze of If and Set nodes chained together to fake what would be three lines of real code. I try to use it sparingly, though — the whole point of the visual canvas is that anyone glancing at the workflow can follow the shape of the data without reading code, and a workflow that’s secretly six Code nodes wired together loses most of that legibility. My rule of thumb: if a transformation needs more than a couple of lines, it goes in a Code node with a comment explaining why; if it’s a single condition or a field rename, it stays as a native node even when the Code node would be marginally faster to write, because the native node is the one future-me will actually understand at a glance during a 3am debugging session.

Troubleshooting

Webhook returns 404 even though the workflow is active. Check WEBHOOK_URL matches the actual public URL exactly, including trailing slash conventions, and that you’re hitting the production URL (/webhook/...) not the test one (/webhook-test/...). n8n registers webhook routes at activation time based on WEBHOOK_URL; changing that variable without deactivating and reactivating affected workflows leaves stale routes registered.

Credentials show as “could not be decrypted.” Classic symptom of N8N_ENCRYPTION_KEY changing or not being set consistently across restarts — if you don’t pin it via environment variable, some deployments generate a random one on first boot and it’s lost the moment the container is recreated. Always set it explicitly.

Workflow execution just hangs with no error. Usually an HTTP Request node calling an endpoint that never returns — n8n’s default node timeout is generous. Set an explicit timeout on any HTTP Request node hitting a third-party API you don’t fully trust, and check EXECUTIONS_TIMEOUT at the instance level as a backstop.

Postgres connection pool exhausted under load. Each active workflow execution can hold a database connection; a burst of webhook triggers on a single-instance (non-queue) deployment can exhaust Postgres’s default max_connections. This is usually the sign you should have turned on queue mode already, or at minimum raise Postgres’s connection limit and add pgbouncer in front of it.

Is it worth it

If your automation needs are “trigger something when a form is submitted” or “post to Discord when a feed updates,” n8n is more setup than a five-minute Zapier click-through, but it pays that setup cost back the first month you would have blown through a metered plan’s task limit. The real win isn’t just cost — it’s that workflows involving credentials for your home services (a database, an internal API, a NAS) never have to leave your network at all, which matters if you’re already careful about exposure the way you would be with something like Cloudflare Tunnels. The trade-off is you’re now the one who patches it, backs up the encryption key, and debugs a stuck workflow at 3am instead of filing a support ticket. For anything more complex than a handful of nodes chained together, that trade is worth making.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.