Rohan Shakya
Automation8 min read

Automating Workflows with n8n: From Zero to Production

A practical guide to n8n — core concepts, building real workflows, the Code node, error handling, self-hosting with Docker, and shipping to production.

  • n8n
  • automation
  • workflow
  • self-hosting
  • devops
Automating Workflows with n8n: From Zero to Production

I've replaced a surprising amount of glue code with n8n over the last couple of years. The scripts that used to live in a forgotten cron job on some VPS — "when a form is submitted, enrich the data, call an API, drop a message in Slack" — are exactly the kind of work that n8n handles better than a one-off Node script nobody remembers maintaining.

This post is the guide I wish I'd had when I started: what n8n actually is, the concepts that matter, how to build a real workflow, and the unglamorous production details that separate a demo from something you trust on call.

What n8n Is

n8n is a workflow automation tool. You build automations visually by wiring together nodes on a canvas, but it never hides the underlying data from you — every node passes JSON, and you can drop into JavaScript or Python whenever the visual approach gets clumsy. That combination is why it appeals to engineers rather than just no-code users.

Two things make it stand out:

  • Fair-code, self-hostable. n8n is source-available under the Sustainable Use License. You can run it yourself for internal use without paying for a cloud subscription, which matters when your workflows touch sensitive data or internal services that never leave your network.
  • It's not a black box. Compared to closed SaaS automation tools, you own the runtime, the data, and the credentials. If something breaks at 2am, you can read the execution logs on your own box.

If you've used Zapier or Make, n8n occupies similar territory, but with self-hosting and a real code escape hatch as first-class features.

Core Concepts

Before building anything, it helps to have a clear mental model. There are only a handful of moving parts.

Nodes

A node is a single step. It might be a trigger, an action (send an email, insert a row), or a transformation. Each node receives an array of items from the previous node, does its work, and emits an array of items to the next one. Internally, every item looks like this:

json
[
  { "json": { "email": "ada@example.com", "plan": "pro" } },
  { "json": { "email": "alan@example.com", "plan": "free" } }
]

Almost every confusing moment in n8n traces back to forgetting that nodes operate on arrays of items, not a single object.

Triggers

A workflow starts with a trigger node. Common ones:

  • Webhook — fires when an HTTP request hits a URL n8n generates.
  • Schedule / Cron — fires on an interval or cron expression.
  • App triggers — polling or push from services like Gmail, GitHub, or a database.

Connections

The lines between nodes. They define order and route data. Some nodes have multiple outputs (for example, an IF node has a true branch and a false branch), and you connect each output independently.

Expressions

Anywhere a field accepts input, you can switch it to an expression and reference data from earlier nodes. Expressions use {{ }} and a small set of helpers.

js
// Reference a field from the immediately previous node
{{ $json.email }}

// Reference a specific upstream node by name
{{ $('Webhook').item.json.body.user_id }}

// Use built-in helpers and JS
{{ $json.name.toUpperCase() }}
{{ $now.toISO() }}
{{ $json.total > 100 ? 'high' : 'normal' }}

Credentials

API keys, OAuth tokens, and database passwords live in n8n's credential store, encrypted at rest with your N8N_ENCRYPTION_KEY. Nodes reference a credential by name — the secret value never appears in the workflow JSON, which means you can export and version workflows without leaking secrets.

Building a Workflow

Let's build something concrete: a webhook receives a lead, we transform it, ask an LLM to draft a short reply, and post a summary to Slack. This is a pattern I use constantly.

The flow is:

scss
Webhook → Set (normalize) → HTTP Request (LLM) → Slack

1. The Webhook trigger

Add a Webhook node, set the method to POST, and copy the test URL. A request like this:

bash
curl -X POST https://n8n.example.com/webhook/lead \
  -H "Content-Type: application/json" \
  -d '{"name": "Ada Lovelace", "email": "ada@example.com", "message": "Tell me about pricing."}'

…arrives in n8n as an item with the payload under $json.body.

2. Normalize with a Set node

Use a Set (Edit Fields) node to shape a clean object. With expressions:

js
name    = {{ $json.body.name }}
email   = {{ $json.body.email }}
message = {{ $json.body.message.trim() }}

3. Call an LLM via HTTP Request

You can use a dedicated AI node, but a plain HTTP Request node keeps things explicit. Point it at your provider's messages endpoint, attach the API key as a header credential, and build the body from the incoming data. The model drafts a reply; we read it from the response in the next node.

4. Notify Slack

A Slack node (or another HTTP Request to an incoming webhook URL) posts the summary:

js
{{ `New lead: *${$('Set').item.json.name}* (${$('Set').item.json.email})\n\nDraft reply:\n${$json.reply}` }}

That's a useful workflow in five nodes and zero deployed code.

The Code Node for Custom Logic

When expressions get awkward — aggregations, reshaping, calling a library — reach for the Code node. It runs JavaScript (or Python) over your items. A common task is deduplicating and grouping:

js
// Code node — "Run Once for All Items"
const seen = new Set();
const deduped = [];

for (const item of $input.all()) {
  const email = item.json.email?.toLowerCase();
  if (!email || seen.has(email)) continue;
  seen.add(email);

  deduped.push({
    json: {
      email,
      name: item.json.name?.trim() ?? 'Unknown',
      receivedAt: $now.toISO(),
    },
  });
}

return deduped;

Two modes matter here:

  • Run Once for All Items — you get the full array via $input.all() and return a new array. Best for aggregation.
  • Run Once for Each Item — the node executes per item; you return a single object. Best for simple per-row transforms.

Always return data in the { json: {...} } shape, or downstream nodes won't see your fields.

Error Workflows and Retries

A workflow that only handles the happy path is a liability. n8n gives you a few layers of defense.

Per-node retries

Most nodes have Settings → Retry On Fail, with a configurable number of attempts and wait time. Turn this on for anything that touches a flaky external API.

Continue on fail

For nodes where a single bad item shouldn't kill the run, enable Continue On Fail. The node emits the error on a separate output you can route to a logging branch.

Error workflows

Create a dedicated workflow that starts with an Error Trigger node, then set it as the error handler in your main workflow's settings. Whenever the main workflow throws, n8n fires the error workflow with details:

js
// Inside the error workflow
const { workflow, execution } = $json;
return [{
  json: {
    text: `Workflow "${workflow.name}" failed.\n` +
          `Execution: ${execution.id}\n` +
          `Error: ${execution.error?.message ?? 'unknown'}`,
  },
}];

Wire that to Slack or PagerDuty and you get real alerting for free.

Scheduling and Cron Triggers

For recurring jobs, use the Schedule Trigger. It supports simple intervals and full cron expressions:

graphql
0 9 * * 1-5    # every weekday at 09:00
*/15 * * * *   # every 15 minutes
0 0 1 * *      # midnight on the 1st of each month

A few hard-won notes: set your instance timezone explicitly via GENERIC_TIMEZONE, because cron silently following UTC is a classic 1am-vs-7am bug. And if a scheduled job can run long, make sure it's idempotent — n8n won't stop a slow run from overlapping the next trigger unless you design for it.

Self-Hosting with Docker

For production I run n8n in Docker behind a reverse proxy, with Postgres as the backing store instead of the default SQLite. Here's a docker-compose.yml that captures the essentials:

yaml
services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: n8n
    volumes:
      - pgdata:/var/lib/postgresql/data

  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - N8N_HOST=n8n.example.com
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.example.com/
      - GENERIC_TIMEZONE=Asia/Kathmandu
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
    depends_on:
      - postgres
    volumes:
      - n8n_data:/home/node/.n8n

volumes:
  pgdata:
  n8n_data:

Key points: pin N8N_ENCRYPTION_KEY to a value you keep safe (losing it means losing access to stored credentials), set WEBHOOK_URL to your public domain so webhook URLs resolve correctly behind a proxy, and use Postgres so executions and credentials survive container restarts. Put TLS termination on something like Caddy or Traefik in front.

AI and Agent Nodes

n8n has grown a solid set of LangChain-based AI nodes. Instead of hand-rolling HTTP calls, you can drop in an AI Agent node, attach a chat model, give it a set of tools (which can be other n8n nodes or sub-workflows), and let it decide what to call. A typical setup wires a chat model, a memory node, and a couple of tool nodes into a single agent.

This is genuinely powerful for support-triage or research workflows, but I treat agent autonomy carefully: I scope the tools tightly, log every tool call, and keep a human approval step before anything writes to production systems. An agent that can only read is a lot easier to trust than one that can send emails on your behalf.

Tips for Production

These are the things that bite you after the demo works.

  • Idempotency. Use a stable key (an order ID, a message ID) and check whether you've already processed it before taking action. Webhooks get retried; schedules overlap. Design so that running twice is harmless.
  • Secrets stay in credentials. Never paste an API key into a node field or a Code node. Use the credential store and, where supported, external secrets via environment variables.
  • Monitor executions. Enable execution logging, ship logs somewhere durable, and set EXECUTIONS_DATA_PRUNE so the database doesn't grow without bound. Pair this with the error workflow for real alerts.
  • Version your workflows. Export workflow JSON into Git, or use n8n's source-control integration. Treat the canvas as code, not as a sacred artifact living only in the UI.
  • Separate environments. Keep a staging instance. Webhook URLs and credentials differ between environments, so don't test new logic against production Slack.
  • Watch the queue. For high volume, run n8n in queue mode with separate worker containers and Redis, so a burst of webhooks doesn't overwhelm a single process.

Final Thoughts

n8n hits a sweet spot I keep coming back to: visual enough to move fast, transparent enough that I never feel locked out of the data, and open enough that I can self-host it next to the systems it automates. Start with one annoying manual process — the kind you do every week and resent — and rebuild it as a workflow. Add an error workflow, make it idempotent, and put it on a schedule. Once the first one is running reliably, you'll find a dozen more candidates hiding in your codebase as forgotten cron jobs.

The tool gives you a lot of rope. Use the production checklist above and it stays a help, not a liability.