← Back to Presentations

n8n Automation Cheatsheet

Self-Hosted Workflow Automation — Docker, Ollama, Practical Examples & Full Workflow JSON

1. Installation & Setup

Quick Start with Docker (Recommended)

# Run n8n with persistent data
docker run -it --rm --name n8n \
  -p 5678:5678 \
  -v n8n_data:/home/node/.n8n \
  docker.n8n.io/n8nio/n8n

# Access UI at http://localhost:5678

Docker Compose with PostgreSQL (Production)

# docker-compose.yml
version: '3.8'
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: always
    ports:
      - "5678:5678"
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - 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_AUTH_PASSWORD}
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_HOST=localhost
      - N8N_PORT=5678
      - N8N_PROTOCOL=http
      - WEBHOOK_URL=http://localhost:5678/
      - GENERIC_TIMEZONE=Europe/Zurich
    volumes:
      - n8n_data:/home/node/.n8n
      - /home/markus/watched:/files/watched  # local folder access
    depends_on:
      - postgres

  postgres:
    image: postgres:16-alpine
    restart: always
    environment:
      - POSTGRES_USER=n8n
      - POSTGRES_PASSWORD=${N8N_DB_PASSWORD}
      - POSTGRES_DB=n8n
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  n8n_data:
  postgres_data:

NPM Global Install

npm install -g n8n
n8n start  # starts on port 5678

Key Environment Variables

VariablePurposeExample
N8N_BASIC_AUTH_USERUI login usernameadmin
N8N_BASIC_AUTH_PASSWORDUI login passwordsupersecret
N8N_ENCRYPTION_KEYEncrypt credentials at restrandom-32-char-string
WEBHOOK_URLExternal webhook base URLhttps://n8n.example.com/
N8N_HOSTBind host0.0.0.0
N8N_PORTListen port5678
N8N_PROTOCOLhttp or httpshttps
GENERIC_TIMEZONETimezone for cron triggersEurope/Zurich
EXECUTIONS_DATA_PRUNEAuto-delete old executionstrue
EXECUTIONS_DATA_MAX_AGEMax age in hours168

2. Core Concepts

Building Blocks

  • Nodes — individual steps (actions, triggers, logic)
  • Triggers — start a workflow (cron, webhook, event)
  • Connections — wires between nodes (data flows left→right)
  • Workflows — a connected graph of nodes
  • Executions — a single run of a workflow
  • Credentials — stored auth (encrypted with N8N_ENCRYPTION_KEY)
  • Pinned Data — mock input for testing nodes
  • Sub-workflows — call one workflow from another

Key Expressions & Variables

// Access current item data
{{ $json.fieldName }}
{{ $json["nested"]["field"] }}

// Previous node output
{{ $node["NodeName"].json.field }}

// Input helpers
{{ $input.first().json.id }}
{{ $input.all().length }}

// Environment variables
{{ $env.MY_SECRET }}

// Execution metadata
{{ $execution.id }}
{{ $execution.mode }}

// Current item index
{{ $itemIndex }}

// Built-in methods
{{ $now.toISO() }}
{{ $today.toISO() }}
{{ DateTime.now().toFormat('yyyy-MM-dd') }}

Error Handling

Error Trigger Workflow

Create a separate workflow with "Error Trigger" node. Set it as the error workflow in Settings → Error Workflow. Gets called on any unhandled failure.

Try/Catch Pattern

On any node: Settings → "Continue On Fail" = true. The error output goes to a separate branch. Check $json.error in the next node.

3. Trigger Nodes

TriggerUse CaseKey Config
Schedule TriggerRun every X minutes/hours/daysCron expression or interval (e.g. every 5 min)
WebhookReceive HTTP POST/GET callsPath, method, auth (Basic/Header)
Email Trigger (IMAP)New email arrivesIMAP host, folder, poll interval
Local File TriggerWatch folder for changesFolder path, events (created/changed/deleted)
RSS Feed TriggerNew RSS feed itemsFeed URL, poll interval
Manual TriggerClick "Execute" in UITesting & development
n8n TriggerCalled by another workflowSub-workflow entry point
MQTT TriggerMQTT message receivedBroker URL, topic, QoS
Postgres TriggerPoll DB for new/changed rowsQuery, poll interval, tracking column

4. Practical Example: File Organizer

Watch a folder → detect file type by extension → rename with date prefix → move to categorized subfolder (Documents/Images/Videos/Code).

Node Flow

[Local File Trigger] → [Code: Classify] → [Switch: File Type] → [Execute Command: Move & Rename]

Full Workflow JSON

{
  "name": "File Organizer",
  "nodes": [
    {
      "parameters": {
        "path": "/files/watched/inbox",
        "events": ["add"],
        "options": {}
      },
      "name": "Watch Folder",
      "type": "n8n-nodes-base.localFileTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "jsCode": "const path = require('path');\nconst items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const filePath = item.json.path;\n  const ext = path.extname(filePath).toLowerCase();\n  const baseName = path.basename(filePath);\n  const datePrefix = new Date().toISOString().slice(0,10);\n  const extMap = {\n    '.pdf': 'Documents', '.doc': 'Documents', '.docx': 'Documents', '.txt': 'Documents', '.xlsx': 'Documents',\n    '.jpg': 'Images', '.jpeg': 'Images', '.png': 'Images', '.gif': 'Images', '.webp': 'Images', '.svg': 'Images',\n    '.mp4': 'Videos', '.mkv': 'Videos', '.avi': 'Videos', '.mov': 'Videos',\n    '.js': 'Code', '.ts': 'Code', '.py': 'Code', '.sh': 'Code', '.go': 'Code', '.rs': 'Code', '.java': 'Code'\n  };\n  const category = extMap[ext] || 'Other';\n  const newName = datePrefix + '_' + baseName;\n  results.push({ json: { filePath, category, newName, baseName, ext } });\n}\nreturn results;"
      },
      "name": "Classify File",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [460, 300]
    },
    {
      "parameters": {
        "command": "=mkdir -p \"/files/watched/{{ $json.category }}\" && mv \"{{ $json.filePath }}\" \"/files/watched/{{ $json.category }}/{{ $json.newName }}\""
      },
      "name": "Move & Rename",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [680, 300]
    }
  ],
  "connections": {
    "Watch Folder": { "main": [[{ "node": "Classify File", "type": "main", "index": 0 }]] },
    "Classify File": { "main": [[{ "node": "Move & Rename", "type": "main", "index": 0 }]] }
  }
}

Tip: Mount your local inbox folder into the Docker container via -v /home/user/Downloads:/files/watched/inbox. New downloads get auto-sorted!

5. Practical Example: Email Digest

Daily at 8am → Fetch unread emails (IMAP) → Filter important senders → Summarize with Ollama (local AI) → Send digest to Telegram.

Node Flow

[Schedule: 8am daily] → [IMAP: Fetch Unread] → [IF: Important Sender] → [Code: Format] → [HTTP: Ollama Summarize] → [Telegram: Send Digest]

Full Workflow JSON

{
  "name": "Daily Email Digest",
  "nodes": [
    {
      "parameters": {
        "rule": { "interval": [{ "triggerAtHour": 8 }] }
      },
      "name": "Every Morning 8am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "mailbox": "INBOX",
        "postProcessAction": "nothing",
        "options": { "unseen": true }
      },
      "name": "Fetch Unread Emails",
      "type": "n8n-nodes-base.imapEmail",
      "typeVersion": 2,
      "position": [440, 300],
      "credentials": { "imap": { "id": "1", "name": "My IMAP" } }
    },
    {
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": false },
          "combinator": "or",
          "conditions": [
            { "leftValue": "={{ $json.from }}", "rightValue": "@company.com", "operator": { "type": "string", "operation": "contains" } },
            { "leftValue": "={{ $json.subject }}", "rightValue": "urgent", "operator": { "type": "string", "operation": "contains" } }
          ]
        }
      },
      "name": "Filter Important",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [640, 300]
    },
    {
      "parameters": {
        "jsCode": "const emails = $input.all();\nlet summary = 'Email Digest:\\n\\n';\nfor (const e of emails) {\n  summary += '• From: ' + e.json.from + '\\n';\n  summary += '  Subject: ' + e.json.subject + '\\n\\n';\n}\nreturn [{ json: { prompt: 'Summarize these emails concisely:\\n' + summary, emails: emails.length } }];"
      },
      "name": "Format for AI",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [840, 300]
    },
    {
      "parameters": {
        "url": "http://host.docker.internal:11434/api/generate",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={ \"model\": \"llama3.1:8b\", \"prompt\": \"{{ $json.prompt }}\", \"stream\": false }"
      },
      "name": "Ollama Summarize",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [1040, 300]
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "=📧 *Daily Email Digest*\n\n{{ $json.response }}"
      },
      "name": "Send to Telegram",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.1,
      "position": [1240, 300],
      "credentials": { "telegramApi": { "id": "2", "name": "My Telegram Bot" } }
    }
  ],
  "connections": {
    "Every Morning 8am": { "main": [[{ "node": "Fetch Unread Emails", "type": "main", "index": 0 }]] },
    "Fetch Unread Emails": { "main": [[{ "node": "Filter Important", "type": "main", "index": 0 }]] },
    "Filter Important": { "main": [[{ "node": "Format for AI", "type": "main", "index": 0 }]] },
    "Format for AI": { "main": [[{ "node": "Ollama Summarize", "type": "main", "index": 0 }]] },
    "Ollama Summarize": { "main": [[{ "node": "Send to Telegram", "type": "main", "index": 0 }]] }
  }
}

Note: Use host.docker.internal to reach Ollama from inside Docker. On Linux, add --add-host=host.docker.internal:host-gateway to your Docker run command.

6. Practical Example: Git Backup Automation

Nightly at 2am → check git status → if changes exist → auto-commit & push → notify via Telegram. Perfect for auto-backing up dotfiles, notes, or config repos.

Node Flow

[Schedule: 2am nightly] → [Exec: git status] → [IF: has changes] → [Exec: git add + commit + push] → [Telegram: notify]

Full Workflow JSON

{
  "name": "Git Auto-Backup",
  "nodes": [
    {
      "parameters": {
        "rule": { "interval": [{ "triggerAtHour": 2 }] }
      },
      "name": "Nightly 2am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "command": "cd /files/my-repo && git status --porcelain"
      },
      "name": "Check Git Status",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [440, 300]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            { "leftValue": "={{ $json.stdout }}", "rightValue": "", "operator": { "type": "string", "operation": "notEmpty" } }
          ]
        }
      },
      "name": "Has Changes?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [640, 300]
    },
    {
      "parameters": {
        "command": "cd /files/my-repo && git add -A && git commit -m \"auto-backup: $(date +%Y-%m-%d_%H:%M)\" && git push origin main 2>&1"
      },
      "name": "Commit & Push",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [840, 250]
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "=✅ *Git Auto-Backup*\nRepo pushed successfully at {{ $now.toFormat('HH:mm') }}\n\n`{{ $json.stdout }}`"
      },
      "name": "Notify Success",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.1,
      "position": [1040, 250],
      "credentials": { "telegramApi": { "id": "2", "name": "My Telegram Bot" } }
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "i️ *Git Auto-Backup*\nNo changes detected tonight."
      },
      "name": "No Changes",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.1,
      "position": [840, 400],
      "credentials": { "telegramApi": { "id": "2", "name": "My Telegram Bot" } }
    }
  ],
  "connections": {
    "Nightly 2am": { "main": [[{ "node": "Check Git Status", "type": "main", "index": 0 }]] },
    "Check Git Status": { "main": [[{ "node": "Has Changes?", "type": "main", "index": 0 }]] },
    "Has Changes?": {
      "main": [
        [{ "node": "Commit & Push", "type": "main", "index": 0 }],
        [{ "node": "No Changes", "type": "main", "index": 0 }]
      ]
    },
    "Commit & Push": { "main": [[{ "node": "Notify Success", "type": "main", "index": 0 }]] }
  }
}

7. Practical Example: System Monitor

Every 5 minutes → check disk usage, RAM, GPU temp → parse output → alert if disk >80% or GPU temp >80°C. Ideal for a local dev machine with NVIDIA GPUs.

Node Flow

[Schedule: every 5min] → [Exec: system stats] → [Code: parse] → [IF: threshold exceeded] → [Telegram: alert]

Full Workflow JSON

{
  "name": "System Monitor",
  "nodes": [
    {
      "parameters": {
        "rule": { "interval": [{ "field": "minutes", "minutesInterval": 5 }] }
      },
      "name": "Every 5 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "command": "echo '---DISK---' && df -h / | tail -1 && echo '---MEM---' && free -m | grep Mem && echo '---GPU---' && nvidia-smi --query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total --format=csv,noheader 2>/dev/null || echo 'NO_GPU'"
      },
      "name": "Get System Stats",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [460, 300]
    },
    {
      "parameters": {
        "jsCode": "const output = $input.first().json.stdout;\nconst lines = output.split('\\n');\n\n// Parse disk\nconst diskLine = lines[1] || '';\nconst diskParts = diskLine.trim().split(/\\s+/);\nconst diskUsedPct = parseInt((diskParts[4] || '0').replace('%', ''));\n\n// Parse memory\nconst memLine = lines.find(l => l.startsWith('Mem:')) || '';\nconst memParts = memLine.trim().split(/\\s+/);\nconst memTotal = parseInt(memParts[1] || '0');\nconst memUsed = parseInt(memParts[2] || '0');\nconst memPct = memTotal > 0 ? Math.round((memUsed / memTotal) * 100) : 0;\n\n// Parse GPU\nconst gpuLine = lines.find(l => l.includes(',') && !l.includes('---')) || '';\nlet gpuTemp = 0, gpuName = 'N/A', gpuUtil = 'N/A', gpuMem = 'N/A';\nif (gpuLine && gpuLine !== 'NO_GPU') {\n  const gp = gpuLine.split(',').map(s => s.trim());\n  gpuName = gp[0]; gpuTemp = parseInt(gp[1]); gpuUtil = gp[2]; gpuMem = gp[3] + '/' + gp[4];\n}\n\nconst alert = diskUsedPct > 80 || gpuTemp > 80 || memPct > 90;\nreturn [{ json: { diskUsedPct, memPct, memUsed, memTotal, gpuName, gpuTemp, gpuUtil, gpuMem, alert } }];"
      },
      "name": "Parse Stats",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [680, 300]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            { "leftValue": "={{ $json.alert }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true" } }
          ]
        }
      },
      "name": "Threshold Exceeded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [900, 300]
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "=🚨 *System Alert*\n\n💾 Disk: {{ $json.diskUsedPct }}%\n🧠 RAM: {{ $json.memUsed }}MB / {{ $json.memTotal }}MB ({{ $json.memPct }}%)\n🎮 GPU: {{ $json.gpuName }}\n🌡️ GPU Temp: {{ $json.gpuTemp }}°C\n📊 GPU Util: {{ $json.gpuUtil }}\n💽 GPU Mem: {{ $json.gpuMem }}"
      },
      "name": "Send Alert",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.1,
      "position": [1120, 250],
      "credentials": { "telegramApi": { "id": "2", "name": "My Telegram Bot" } }
    }
  ],
  "connections": {
    "Every 5 Minutes": { "main": [[{ "node": "Get System Stats", "type": "main", "index": 0 }]] },
    "Get System Stats": { "main": [[{ "node": "Parse Stats", "type": "main", "index": 0 }]] },
    "Parse Stats": { "main": [[{ "node": "Threshold Exceeded?", "type": "main", "index": 0 }]] },
    "Threshold Exceeded?": { "main": [[{ "node": "Send Alert", "type": "main", "index": 0 }]] }
  }
}

Important: For nvidia-smi to work inside Docker, add --gpus all or use the NVIDIA container runtime. For host-level monitoring, mount /usr/bin/nvidia-smi and the NVIDIA libs.

8. Practical Example: Web Scraper + Database

Daily → fetch a webpage → extract data with HTML Extract (Cheerio) → upsert to Postgres → notify on new entries. Practical for price monitoring, job listings, etc.

Node Flow

[Schedule: daily 6am] → [HTTP Request: fetch page] → [HTML Extract] → [Code: transform] → [Postgres: upsert] → [IF: new rows] → [Telegram: notify]

Full Workflow JSON

{
  "name": "Web Scraper + Postgres",
  "nodes": [
    {
      "parameters": {
        "rule": { "interval": [{ "triggerAtHour": 6 }] }
      },
      "name": "Daily 6am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "url": "https://example.com/listings",
        "options": { "response": { "response": { "fullResponse": true } } }
      },
      "name": "Fetch Page",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [440, 300]
    },
    {
      "parameters": {
        "operation": "extractHtmlContent",
        "extractionValues": {
          "values": [
            { "key": "title", "cssSelector": ".listing-title", "returnValue": "text" },
            { "key": "price", "cssSelector": ".listing-price", "returnValue": "text" },
            { "key": "link", "cssSelector": ".listing-title a", "returnValue": "attribute", "attribute": "href" }
          ]
        }
      },
      "name": "Extract Data",
      "type": "n8n-nodes-base.html",
      "typeVersion": 1.2,
      "position": [640, 300]
    },
    {
      "parameters": {
        "jsCode": "const titles = $input.first().json.title;\nconst prices = $input.first().json.price;\nconst links = $input.first().json.link;\nconst items = [];\nfor (let i = 0; i < titles.length; i++) {\n  items.push({\n    json: {\n      title: titles[i],\n      price: prices[i],\n      link: links[i],\n      scraped_at: new Date().toISOString()\n    }\n  });\n}\nreturn items;"
      },
      "name": "Transform",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [840, 300]
    },
    {
      "parameters": {
        "operation": "upsert",
        "table": "scraped_listings",
        "columns": "title, price, link, scraped_at",
        "conflictColumns": "link"
      },
      "name": "Upsert to Postgres",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.3,
      "position": [1040, 300],
      "credentials": { "postgres": { "id": "3", "name": "Local Postgres" } }
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "=🔍 *Scraper Update*\nProcessed {{ $input.all().length }} listings"
      },
      "name": "Notify",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.1,
      "position": [1240, 300],
      "credentials": { "telegramApi": { "id": "2", "name": "My Telegram Bot" } }
    }
  ],
  "connections": {
    "Daily 6am": { "main": [[{ "node": "Fetch Page", "type": "main", "index": 0 }]] },
    "Fetch Page": { "main": [[{ "node": "Extract Data", "type": "main", "index": 0 }]] },
    "Extract Data": { "main": [[{ "node": "Transform", "type": "main", "index": 0 }]] },
    "Transform": { "main": [[{ "node": "Upsert to Postgres", "type": "main", "index": 0 }]] },
    "Upsert to Postgres": { "main": [[{ "node": "Notify", "type": "main", "index": 0 }]] }
  }
}

Postgres Table Setup

CREATE TABLE scraped_listings (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  price TEXT,
  link TEXT UNIQUE NOT NULL,
  scraped_at TIMESTAMPTZ DEFAULT NOW(),
  created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_listings_link ON scraped_listings(link);

9. Practical Example: AI Document Processor

Watch folder for PDFs → extract text with pdftotext → summarize with Ollama (local LLM, no cloud APIs!) → save summary to file + log to Postgres.

Node Flow

[File Trigger: new PDF] → [Exec: pdftotext] → [HTTP: Ollama summarize] → [Exec: write summary] → [Postgres: log] → [Telegram: notify]

Full Workflow JSON

{
  "name": "AI Document Processor",
  "nodes": [
    {
      "parameters": {
        "path": "/files/watched/pdfs",
        "events": ["add"],
        "options": { "ignored": "**/*.!(pdf)" }
      },
      "name": "Watch PDFs",
      "type": "n8n-nodes-base.localFileTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "command": "=pdftotext \"{{ $json.path }}\" - | head -5000"
      },
      "name": "Extract Text",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [460, 300]
    },
    {
      "parameters": {
        "url": "http://host.docker.internal:11434/api/generate",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={ \"model\": \"llama3.1:8b\", \"prompt\": \"Summarize this document concisely in bullet points:\\n\\n{{ $json.stdout.substring(0, 4000) }}\", \"stream\": false }",
        "options": { "timeout": 120000 }
      },
      "name": "Ollama Summarize",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [680, 300]
    },
    {
      "parameters": {
        "command": "=echo '{{ $json.response.replace(/'/g, "'\\''") }}' > \"/files/watched/summaries/$(basename '{{ $node[\"Watch PDFs\"].json.path }}' .pdf)_summary.txt\""
      },
      "name": "Save Summary",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [900, 300]
    },
    {
      "parameters": {
        "operation": "insert",
        "table": "document_summaries",
        "columns": "filename, summary, processed_at",
        "options": {}
      },
      "name": "Log to DB",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.3,
      "position": [1120, 300],
      "credentials": { "postgres": { "id": "3", "name": "Local Postgres" } }
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "=📄 *Document Processed*\nFile: {{ $node[\"Watch PDFs\"].json.path }}\n\n{{ $node[\"Ollama Summarize\"].json.response.substring(0, 500) }}"
      },
      "name": "Notify",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.1,
      "position": [1340, 300],
      "credentials": { "telegramApi": { "id": "2", "name": "My Telegram Bot" } }
    }
  ],
  "connections": {
    "Watch PDFs": { "main": [[{ "node": "Extract Text", "type": "main", "index": 0 }]] },
    "Extract Text": { "main": [[{ "node": "Ollama Summarize", "type": "main", "index": 0 }]] },
    "Ollama Summarize": { "main": [[{ "node": "Save Summary", "type": "main", "index": 0 }]] },
    "Save Summary": { "main": [[{ "node": "Log to DB", "type": "main", "index": 0 }]] },
    "Log to DB": { "main": [[{ "node": "Notify", "type": "main", "index": 0 }]] }
  }
}

Prerequisites: Install poppler-utils (for pdftotext) in your n8n Docker image or mount from host. Ollama must be running on the host with a model pulled (ollama pull llama3.1:8b).

10. Practical Example: Invoice Automation

Webhook receives order data → generate PDF invoice with Code node → send email → log to Postgres. Perfect for freelancers and small businesses.

Node Flow

[Webhook: POST /invoice] → [Code: generate invoice HTML] → [HTML to PDF] → [Send Email + attachment] → [Postgres: log]

Full Workflow JSON

{
  "name": "Invoice Automation",
  "nodes": [
    {
      "parameters": {
        "path": "invoice",
        "httpMethod": "POST",
        "responseMode": "responseNode",
        "options": {}
      },
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1.1,
      "position": [240, 300],
      "webhookId": "invoice-gen"
    },
    {
      "parameters": {
        "jsCode": "const order = $input.first().json.body;\nconst invoiceNum = 'INV-' + Date.now();\nconst items = order.items || [];\nlet total = 0;\nlet rows = '';\nfor (const item of items) {\n  const lineTotal = item.qty * item.price;\n  total += lineTotal;\n  rows += '<tr><td>' + item.name + '</td><td>' + item.qty + '</td><td>CHF ' + item.price.toFixed(2) + '</td><td>CHF ' + lineTotal.toFixed(2) + '</td></tr>';\n}\nconst html = '<!DOCTYPE html><html><head><style>body{font-family:Arial,sans-serif;margin:40px}table{width:100%;border-collapse:collapse;margin-top:20px}th,td{border:1px solid #ddd;padding:8px;text-align:left}th{background:#f5f5f5}.total{font-size:1.2em;font-weight:bold;margin-top:20px}</style></head><body><h1>Invoice ' + invoiceNum + '</h1><p>Date: ' + new Date().toISOString().slice(0,10) + '</p><p>To: ' + (order.customer_name || 'Customer') + '</p><p>' + (order.customer_email || '') + '</p><table><tr><th>Item</th><th>Qty</th><th>Price</th><th>Total</th></tr>' + rows + '</table><p class=\"total\">Total: CHF ' + total.toFixed(2) + '</p><hr><p><small>Veloz Engineering — velozengineering.ch</small></p></body></html>';\nreturn [{ json: { invoiceNum, html, total, customer_name: order.customer_name, customer_email: order.customer_email, items } }];"
      },
      "name": "Generate Invoice HTML",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [460, 300]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.customer_email }}",
        "subject": "=Invoice {{ $json.invoiceNum }}",
        "emailType": "html",
        "html": "={{ $json.html }}",
        "options": {}
      },
      "name": "Send Email",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [680, 300],
      "credentials": { "smtp": { "id": "4", "name": "SMTP" } }
    },
    {
      "parameters": {
        "operation": "insert",
        "table": "invoices",
        "columns": "invoice_number, customer_name, customer_email, total, created_at"
      },
      "name": "Log to DB",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.3,
      "position": [900, 300],
      "credentials": { "postgres": { "id": "3", "name": "Local Postgres" } }
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={ \"success\": true, \"invoice\": \"{{ $node[\"Generate Invoice HTML\"].json.invoiceNum }}\" }"
      },
      "name": "Respond",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [1120, 300]
    }
  ],
  "connections": {
    "Webhook": { "main": [[{ "node": "Generate Invoice HTML", "type": "main", "index": 0 }]] },
    "Generate Invoice HTML": { "main": [[{ "node": "Send Email", "type": "main", "index": 0 }]] },
    "Send Email": { "main": [[{ "node": "Log to DB", "type": "main", "index": 0 }]] },
    "Log to DB": { "main": [[{ "node": "Respond", "type": "main", "index": 0 }]] }
  }
}

Test the Webhook

curl -X POST http://localhost:5678/webhook/invoice \
  -H "Content-Type: application/json" \
  -d '{
    "customer_name": "Max Mustermann",
    "customer_email": "[email protected]",
    "items": [
      {"name": "Consulting (1h)", "qty": 4, "price": 150},
      {"name": "Server Setup", "qty": 1, "price": 500}
    ]
  }'

11. Local AI Integration

n8n + Ollama = fully local AI workflows. No API keys, no cloud costs, full privacy. Your NVIDIA GPUs do the heavy lifting.

Ollama API via HTTP Request Node

Generate (completion)

// HTTP Request node config:
URL: http://host.docker.internal:11434/api/generate
Method: POST
Body (JSON):
{
  "model": "llama3.1:8b",
  "prompt": "{{ $json.text }}",
  "stream": false,
  "options": {
    "temperature": 0.7,
    "num_predict": 500
  }
}
// Response: $json.response

Chat (multi-turn)

// HTTP Request node config:
URL: http://host.docker.internal:11434/api/chat
Method: POST
Body (JSON):
{
  "model": "llama3.1:8b",
  "messages": [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "{{ $json.question }}"}
  ],
  "stream": false
}
// Response: $json.message.content

Embeddings for RAG

// HTTP Request to Ollama embeddings endpoint
URL: http://host.docker.internal:11434/api/embeddings
Method: POST
Body:
{
  "model": "nomic-embed-text",
  "prompt": "{{ $json.text }}"
}
// Response: $json.embedding (array of floats)

Local RAG Pipeline: n8n + Ollama + Qdrant

Ingest Pipeline:

[File Trigger] → [Extract Text] → [Code: Chunk text] → [HTTP: Ollama embed] → [HTTP: Qdrant upsert]

Query Pipeline:

[Webhook: question] → [HTTP: Ollama embed query] → [HTTP: Qdrant search] → [Code: build context] → [HTTP: Ollama generate with context] → [Respond]

Qdrant Docker Setup (for vector storage)

# Add to docker-compose.yml
qdrant:
  image: qdrant/qdrant:latest
  ports:
    - "6333:6333"
  volumes:
    - qdrant_data:/qdrant/storage

# Create collection
curl -X PUT http://localhost:6333/collections/documents \
  -H "Content-Type: application/json" \
  -d '{"vectors": {"size": 768, "distance": "Cosine"}}'

# Upsert vectors (from n8n HTTP Request)
curl -X PUT http://localhost:6333/collections/documents/points \
  -H "Content-Type: application/json" \
  -d '{"points": [{"id": 1, "vector": [...], "payload": {"text": "chunk content"}}]}'

# Search (from n8n HTTP Request)
curl -X POST http://localhost:6333/collections/documents/points/search \
  -H "Content-Type: application/json" \
  -d '{"vector": [...], "limit": 5}'

n8n Built-in AI Nodes (v1.19+)

  • AI Agent — autonomous agent with tools (search, code, etc.)
  • LangChain nodes — chain, memory, tools, output parsers
  • Vector Store nodes — Qdrant, Pinecone, Supabase built-in
  • Text Splitter — chunk documents for embedding
  • Embeddings node — connects to Ollama natively
  • Set Ollama as your LLM provider in n8n AI settings (no API key needed!)

12. Microsoft 365 Setup (Teams + Outlook)

Azure AD App Registration

n8n connects to Teams & Outlook via Microsoft Graph API. You need an Azure AD App:

⚙️ Setup Steps:
  1. Go to portal.azure.com → Azure Active Directory → App Registrations → New
  2. Name: "n8n Automation", Redirect URI: http://localhost:5678/rest/oauth2-credential/callback
  3. Note the Application (client) ID and Directory (tenant) ID
  4. Certificates & Secrets → New client secret → copy the Value
  5. API Permissions → Add: Mail.ReadWrite, Mail.Send, Calendars.ReadWrite, ChannelMessage.Send, Chat.ReadWrite, User.Read
  6. Grant admin consent (or request from IT admin)

n8n Credential Setup

In n8n → Settings → Credentials → Add "Microsoft OAuth2":

# n8n Microsoft credential config
Client ID:     <your-app-client-id>
Client Secret: <your-app-secret>
Tenant ID:     <your-tenant-id>   # or "common" for multi-tenant
Scope:         https://graph.microsoft.com/.default

# For personal/work accounts, use:
Auth URL:  https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize
Token URL: https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token

Microsoft Graph API Quick Reference

ActionEndpointPermission
List emailsGET /me/messagesMail.Read
Send emailPOST /me/sendMailMail.Send
List calendar eventsGET /me/eventsCalendars.Read
Create eventPOST /me/eventsCalendars.ReadWrite
Send Teams messagePOST /teams/{id}/channels/{id}/messagesChannelMessage.Send
Send chat messagePOST /chats/{id}/messagesChat.ReadWrite
List TeamsGET /me/joinedTeamsTeam.ReadBasic.All
Get presenceGET /me/presencePresence.Read

13. Example: Teams Automations

Daily Standup Reminder + Collector

Sends a standup prompt to a Teams channel every morning, collects replies, and posts a summary:

// Flow: Schedule (Mon-Fri 9:00) → Teams: Send Message → Wait (2h) → 
//        Teams: Get Replies → Code: Format Summary → Teams: Post Summary

// n8n Workflow JSON (import via CLI or paste in editor)
{
  "nodes": [
    {
      "name": "Daily 9 AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": {
        "rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 * * 1-5" }] }
      },
      "position": [250, 300]
    },
    {
      "name": "Post Standup Prompt",
      "type": "n8n-nodes-base.microsoftTeams",
      "parameters": {
        "resource": "channelMessage",
        "operation": "create",
        "teamId": "={{ $env.TEAMS_TEAM_ID }}",
        "channelId": "={{ $env.TEAMS_CHANNEL_ID }}",
        "contentType": "html",
        "message": "<b>🟢 Daily Standup</b><br>Please reply with:<br>1. What did you do yesterday?<br>2. What are you doing today?<br>3. Any blockers?"
      },
      "position": [450, 300]
    }
  ]
}

Deployment Notification to Teams

Webhook triggered by CI/CD pipeline → posts deployment status to Teams channel with Adaptive Card:

// Flow: Webhook (POST /deploy-notify) → Code: Build Card → Teams: Post

// Webhook receives:
// { "app": "my-service", "env": "production", "status": "success", 
//   "version": "1.2.3", "deployer": "markus", "url": "https://..." }

// Code Node — Build Adaptive Card:
const payload = $input.first().json;
const color = payload.status === 'success' ? '00FF00' : 'FF0000';
const emoji = payload.status === 'success' ? '✅' : '❌';

return [{
  json: {
    teamId: $env.TEAMS_TEAM_ID,
    channelId: $env.TEAMS_CHANNEL_ID,
    contentType: "html",
    message: `<b>${emoji} Deployment ${payload.status.toUpperCase()}</b><br>
      <b>App:</b> ${payload.app}<br>
      <b>Version:</b> ${payload.version}<br>
      <b>Environment:</b> ${payload.env}<br>
      <b>Deployed by:</b> ${payload.deployer}<br>
      <b>URL:</b> <a href="${payload.url}">${payload.url}</a>`
  }
}];

On-Call / Incident Alert

Webhook from monitoring (Prometheus/Grafana) → post urgent alert to Teams + tag on-call person:

// Flow: Webhook → Code: Format Alert → Teams: Post → IF critical → 
//        Teams: Send Chat to On-Call Person

// Code Node:
const alert = $input.first().json;
const severity = alert.status === 'firing' ? '🔴 CRITICAL' : '🟢 Resolved';
const onCall = {
  'mon': '[email protected]', 'tue': '[email protected]',
  'wed': '[email protected]', 'thu': '[email protected]',
  'fri': '[email protected]'
};
const today = ['sun','mon','tue','wed','thu','fri','sat'][new Date().getDay()];

return [{
  json: {
    message: `<b>${severity}</b><br>
      <b>Alert:</b> ${alert.labels?.alertname || 'Unknown'}<br>
      <b>Service:</b> ${alert.labels?.service || 'N/A'}<br>
      <b>Description:</b> ${alert.annotations?.description || ''}<br>
      <b>On-Call:</b> ${onCall[today] || 'unassigned'}`,
    onCallEmail: onCall[today],
    isCritical: alert.status === 'firing'
  }
}];

Teams Presence → Auto Status

Check your Teams presence and auto-update a status page or mute notifications:

// Flow: Schedule (every 5min) → Microsoft Teams: Get Presence → 
//        IF "InAMeeting" → HTTP: Update Status Page / Mute Alerts

// HTTP Request Node — Check presence:
Method: GET
URL: https://graph.microsoft.com/v1.0/me/presence
Headers: Authorization = Bearer {{ $credentials.microsoftOAuth2.accessToken }}

// Response:
// { "availability": "Busy", "activity": "InAMeeting" }
// Possible: Available, Busy, DoNotDisturb, BeRightBack, Away, Offline
// Activities: InACall, InAMeeting, Presenting, Available, etc.

// IF Node condition: 
// {{ $json.activity }} equals "InAMeeting" → mute route
// {{ $json.availability }} equals "DoNotDisturb" → suppress alerts

Jira/ServiceNow → Teams Bridge

When a Jira ticket is assigned to you, post it to your Teams channel:

// Flow: Webhook (Jira) → IF assignee=me → Code: Format → Teams: Post

// Jira Webhook payload extract (Code Node):
const issue = $input.first().json;
return [{
  json: {
    message: `<b>📋 New Ticket Assigned</b><br>
      <b>${issue.key}</b>: ${issue.fields.summary}<br>
      <b>Priority:</b> ${issue.fields.priority.name}<br>
      <b>Type:</b> ${issue.fields.issuetype.name}<br>
      <a href="https://jira.company.com/browse/${issue.key}">Open in Jira →</a>`
  }
}];

14. Example: Outlook Automations

Smart Email Categorizer with AI

Fetch unread emails → classify with Ollama → auto-move to folders → daily summary:

// Flow: Schedule (every 30min) → Outlook: Get Unread → 
//        Loop → Ollama: Classify → Outlook: Move to Folder → 
//        Aggregate → IF end of day → Teams: Post Summary

// Step 1: Fetch unread (HTTP Request or Microsoft Outlook node)
Method: GET
URL: https://graph.microsoft.com/v1.0/me/mailFolders/Inbox/messages
     ?$filter=isRead eq false&$top=50&$select=subject,from,bodyPreview,receivedDateTime

// Step 2: Ollama Classification (HTTP Request Node)
Method: POST
URL: http://localhost:11434/api/generate
Body:
{
  "model": "llama3.1:8b",
  "prompt": "Classify this email into exactly ONE category: ACTION_REQUIRED, FYI, MEETING, JIRA, APPROVAL, SPAM. Only reply with the category name.\n\nFrom: {{ $json.from.emailAddress.address }}\nSubject: {{ $json.subject }}\nPreview: {{ $json.bodyPreview }}",
  "stream": false
}

// Step 3: Move to folder (HTTP Request Node)  
Method: POST
URL: https://graph.microsoft.com/v1.0/me/messages/{{ $json.id }}/move
Body: { "destinationId": "{{ $json.folderId }}" }

// Folder mapping (Code Node):
const folderMap = {
  'ACTION_REQUIRED': 'AAMkAG...actionFolder',
  'FYI': 'AAMkAG...fyiFolder',
  'MEETING': 'AAMkAG...meetingFolder',
  'JIRA': 'AAMkAG...jiraFolder',
  'APPROVAL': 'AAMkAG...approvalFolder',
  'SPAM': 'AAMkAG...junkFolder'
};

Calendar → Daily Agenda Email

Every morning: fetch today's calendar events → format nice agenda → send to yourself or Teams:

// Flow: Schedule (Mon-Fri 7:30) → Outlook: Get Events → Code: Format → 
//        Send Email / Teams Message

// Fetch today's events (HTTP Request):
Method: GET
URL: https://graph.microsoft.com/v1.0/me/calendarView
     ?startDateTime={{ $now.startOf('day').toISO() }}
     &endDateTime={{ $now.endOf('day').toISO() }}
     &$orderby=start/dateTime
     &$select=subject,start,end,location,organizer,isOnlineMeeting,onlineMeeting

// Code Node — Format Agenda:
const events = $input.first().json.value || [];
if (events.length === 0) return [{ json: { agenda: 'No meetings today! 🎉', count: 0 }}];

let agenda = '<b>📅 Your Agenda — ' + new Date().toLocaleDateString('de-CH') + '</b><br><br>';
let totalMeetingHours = 0;

for (const evt of events) {
  const start = new Date(evt.start.dateTime + 'Z');
  const end = new Date(evt.end.dateTime + 'Z');
  const duration = (end - start) / 3600000;
  totalMeetingHours += duration;
  
  const time = start.toLocaleTimeString('de-CH', {hour:'2-digit', minute:'2-digit'})
    + ' - ' + end.toLocaleTimeString('de-CH', {hour:'2-digit', minute:'2-digit'});
  const meetLink = evt.isOnlineMeeting ? 
    ' <a href="' + (evt.onlineMeeting?.joinUrl || '#') + '">Join →</a>' : '';
  
  agenda += `<b>${time}</b> ${evt.subject}${meetLink}<br>`;
  if (evt.location?.displayName) agenda += `  📍 ${evt.location.displayName}<br>`;
}

agenda += `<br><b>Total meeting time: ${totalMeetingHours.toFixed(1)}h</b>`;
if (totalMeetingHours > 6) agenda += ' ⚠️ Heavy meeting day!';

return [{ json: { agenda, count: events.length, hours: totalMeetingHours }}];

Auto-Reply When Busy

If you're in a meeting and receive an email marked urgent, auto-reply with ETA:

// Flow: Outlook Trigger (new email) → IF importance=high → 
//        Get Presence → IF InAMeeting → Get Current Event End Time → 
//        Outlook: Reply with ETA

// Code Node — Build auto-reply:
const email = $('Outlook Trigger').first().json;
const currentEvent = $('Get Calendar').first().json.value?.[0];
const endTime = currentEvent ? 
  new Date(currentEvent.end.dateTime + 'Z').toLocaleTimeString('de-CH', 
    {hour:'2-digit', minute:'2-digit'}) : 'soon';

return [{
  json: {
    to: email.from.emailAddress.address,
    subject: 'Re: ' + email.subject,
    body: `Hi,\n\nI'm currently in a meeting and will get back to you around ${endTime}.\n\nIf this is truly urgent, please reach me on Teams.\n\nBest regards,\nMarkus`,
    inReplyTo: email.id
  }
}];

// Send Reply (HTTP Request):
Method: POST
URL: https://graph.microsoft.com/v1.0/me/messages/{{ $json.inReplyTo }}/reply
Body: { "message": { "body": { "content": "{{ $json.body }}", "contentType": "text" }}}

Meeting Notes Extractor

After a meeting ends → check for Teams meeting transcript → summarize with AI → email notes to attendees:

// Flow: Schedule (every 15min) → Get Recently Ended Meetings → 
//        Loop → Get Transcript → Ollama: Summarize → 
//        Send Notes Email to Attendees

// Fetch meetings that ended in last 15 min:
Method: GET
URL: https://graph.microsoft.com/v1.0/me/calendarView
     ?startDateTime={{ $now.minus({minutes: 20}).toISO() }}
     &endDateTime={{ $now.minus({minutes: 1}).toISO() }}
     &$filter=isOnlineMeeting eq true

// Ollama Summarize (HTTP Request):
Method: POST
URL: http://localhost:11434/api/generate
Body: {
  "model": "llama3.1:8b",
  "prompt": "Summarize these meeting notes. Include: Key Decisions, Action Items (with owners), Open Questions.\n\nTranscript:\n{{ $json.transcript }}",
  "stream": false
}

Attachment Auto-Saver

Auto-download attachments from specific senders to organized local folders:

// Flow: Schedule (every 10min) → Outlook: Get Emails with Attachments → 
//        Loop → Download Attachment → Categorize → Save to Folder

// Fetch emails with attachments:
Method: GET  
URL: https://graph.microsoft.com/v1.0/me/messages
     ?$filter=hasAttachments eq true and isRead eq false
     &$expand=attachments&$top=20

// Code Node — Save logic:
const msg = $input.first().json;
const senderRules = {
  'invoice': /buchhaltung|accounting|rechnung|invoice/i,
  'contracts': /vertrag|contract|legal/i,
  'reports': /report|bericht|weekly|monthly/i,
};

for (const att of msg.attachments || []) {
  if (att['@odata.type'] !== '#microsoft.graph.fileAttachment') continue;
  
  let folder = 'general';
  for (const [cat, regex] of Object.entries(senderRules)) {
    if (regex.test(msg.subject) || regex.test(msg.from.emailAddress.address)) {
      folder = cat; break;
    }
  }
  
  return [{ 
    json: { 
      filename: att.name,
      content: att.contentBytes, // base64
      folder: '/home/markus/Documents/outlook-attachments/' + folder,
      sender: msg.from.emailAddress.address
    }
  }];
}

// Write File Node: 
// Path: {{ $json.folder }}/{{ $json.filename }}
// Content: {{ $json.content }} (base64 decode)

Weekly Time Report

Every Friday: analyze your week's calendar → generate time report → email to yourself:

// Flow: Schedule (Fri 17:00) → Get This Week's Events → 
//        Code: Analyze → Format Report → Email

// Code Node — Weekly Analysis:
const events = $input.first().json.value || [];
const categories = {};
let totalHours = 0;

for (const evt of events) {
  const start = new Date(evt.start.dateTime + 'Z');
  const end = new Date(evt.end.dateTime + 'Z');
  const hours = (end - start) / 3600000;
  totalHours += hours;
  
  // Categorize by keywords in subject
  let cat = 'Other';
  if (/standup|daily|sync/i.test(evt.subject)) cat = 'Standups & Syncs';
  else if (/review|code review|pr/i.test(evt.subject)) cat = 'Code Reviews';
  else if (/planning|sprint|retro|grooming/i.test(evt.subject)) cat = 'Agile Ceremonies';
  else if (/1:1|1-1|one.on.one/i.test(evt.subject)) cat = '1:1 Meetings';
  else if (/workshop|training/i.test(evt.subject)) cat = 'Workshops';
  else if (/interview/i.test(evt.subject)) cat = 'Interviews';
  
  categories[cat] = (categories[cat] || 0) + hours;
}

const sorted = Object.entries(categories).sort((a,b) => b[1] - a[1]);
let report = '<b>📊 Weekly Meeting Report</b><br><br>';
report += '<b>Total: ' + totalHours.toFixed(1) + 'h in meetings</b><br>';
report += '<b>Focus time: ' + (40 - totalHours).toFixed(1) + 'h available</b><br><br>';

for (const [cat, hrs] of sorted) {
  const bar = '█'.repeat(Math.round(hrs * 2)) + '░'.repeat(Math.round((10-hrs)*2));
  report += `${cat}: ${hrs.toFixed(1)}h ${bar}<br>`;
}

if (totalHours > 25) report += '<br>⚠️ Over 60% in meetings — consider declining some!';

return [{ json: { report, totalHours }}];

12. Advanced Patterns & Tips

CLI Commands

# Execute a workflow by ID
n8n execute --id 5

# Export all workflows
n8n export:workflow --all --output=./backups/

# Export single workflow
n8n export:workflow --id 5 --output=./workflow-5.json

# Import workflows
n8n import:workflow --input=./workflow-5.json

# Export credentials (encrypted)
n8n export:credentials --all --output=./creds/

# Import credentials
n8n import:credentials --input=./creds/creds.json

# List workflows
n8n list:workflow

Error Handling Patterns

Global Error Workflow

// Create a workflow with "Error Trigger" node
// → Receives: execution ID, workflow name, error message
// → Send alert to Telegram/Email/Slack
// Set in: Settings → Error Workflow → select it

Retry on Fail

// On any node → Settings:
//   "Retry On Fail" = true
//   "Max Tries" = 3
//   "Wait Between Tries (ms)" = 1000
// 
// Great for flaky APIs and network calls

Webhook Security

// Method 1: Basic Auth on webhook
// Webhook node → Authentication → Basic Auth

// Method 2: Header validation in Code node
const authHeader = $input.first().json.headers['x-api-key'];
if (authHeader !== $env.WEBHOOK_SECRET) {
  throw new Error('Unauthorized');
}

// Method 3: IP allowlist in Code node
const clientIp = $input.first().json.headers['x-forwarded-for'];
const allowed = ['192.168.1.0/24', '10.0.0.0/8'];
// validate...

Performance: Queue Mode with Redis

# docker-compose addition for scaling
redis:
  image: redis:7-alpine
  ports:
    - "6379:6379"

n8n-worker:
  image: docker.n8n.io/n8nio/n8n
  command: worker
  environment:
    - EXECUTIONS_MODE=queue
    - QUEUE_BULL_REDIS_HOST=redis
    - QUEUE_BULL_REDIS_PORT=6379
    # ... same DB config as main n8n

# Main n8n also needs:
# EXECUTIONS_MODE=queue
# QUEUE_BULL_REDIS_HOST=redis

n8n REST API

# Enable API: set N8N_PUBLIC_API_DISABLED=false
# Generate API key in n8n UI: Settings → API

# List workflows
curl -H "X-N8N-API-KEY: your-key" http://localhost:5678/api/v1/workflows

# Execute a workflow
curl -X POST -H "X-N8N-API-KEY: your-key" \
  http://localhost:5678/api/v1/workflows/5/activate

# Get executions
curl -H "X-N8N-API-KEY: your-key" \
  http://localhost:5678/api/v1/executions?workflowId=5&limit=10

Backup Strategy

#!/bin/bash
# backup-n8n.sh — run nightly via cron
BACKUP_DIR="/backups/n8n/$(date +%Y-%m-%d)"
mkdir -p "$BACKUP_DIR"

# Export all workflows
docker exec n8n n8n export:workflow --all --output=/tmp/workflows/
docker cp n8n:/tmp/workflows/ "$BACKUP_DIR/workflows/"

# Export credentials
docker exec n8n n8n export:credentials --all --output=/tmp/creds.json
docker cp n8n:/tmp/creds.json "$BACKUP_DIR/credentials.json"

# Postgres dump
docker exec postgres pg_dump -U n8n n8n > "$BACKUP_DIR/n8n-db.sql"

# Compress
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
rm -rf "$BACKUP_DIR"

echo "Backup complete: $BACKUP_DIR.tar.gz"

Quick Tips

  • ▸Use sub-workflows for reusable logic (error handling, notifications)
  • ▸Store secrets in environment variables, never in workflow JSON
  • ▸Use Pinned Data to mock inputs during development
  • ▸Enable execution pruning to avoid DB bloat
  • ▸Install community nodes via Settings → Community Nodes
  • ▸Version control workflows: export JSON → commit to Git
  • ▸Use tags to organize workflows (e.g. "production", "monitoring")
  • ▸Set GENERIC_TIMEZONE so cron triggers fire at the right time