n8n Workflow Basics, Triggers, Nodes, Connections
TL;DR — A workflow = a trigger + nodes wired by connections. Triggers: cron, webhook, polling. Nodes: built-in (Slack, Jira, HTTP, etc.) plus a Code node for JS. Expressions reference upstream node output via
{{ $json.field }}. When the visual gets ugly, drop into Code.
After self-hosting, the actual fun part: building workflows. This post is the conceptual primer — what the components are, how they fit, and the syntax you’ll be reading and writing for the rest of the month.
I’ll use a simple example throughout: “every weekday at 8:30 AM, fetch yesterday’s GitHub PRs from a repo and post a summary to Slack.” Realistic standup-prep workflow.
The four building blocks
1. Workflow — the unit of automation. One trigger, many nodes, one or more outcomes.
2. Trigger — what starts the workflow. n8n has several built-in:
- Schedule trigger (cron) — “every day at 8:30 AM”
- Webhook trigger — “when this URL receives POST”
- App-specific triggers — “when a new Jira issue is created”
- Polling triggers — “every 5 min, check for new rows in this DB”
- Manual trigger — only when you click the Execute button (for testing)
A workflow needs exactly one active trigger. (You can have multiple disabled while testing.)
3. Node — a step that does work. The big categories:
- Action nodes — call an API, send a message, query a DB
- Logic nodes — IF, Switch, Merge, Filter
- Data nodes — Set, Edit Fields, Function (JS), Code (JS w/ multiple items)
- Trigger nodes — same as above, but as the workflow entry
Each node receives data from previous nodes via connections.
4. Connection — an arrow from one node’s output to another’s input. Most nodes have one output; logic nodes (IF, Switch) have multiple.
A working example
The standup-prep workflow:
[Schedule Trigger: weekday 8:30 AM]
↓
[GitHub: List Pull Requests, state=closed, updated since yesterday]
↓
[Filter: PR.merged_at >= yesterday 00:00]
↓
[Code: format as Slack-flavored markdown summary]
↓
[Slack: Post Message to #standup]
Five nodes. Most of the work happens in the GitHub fetch and the Code formatter; the rest is glue.
Schedule trigger
Configure the cron expression — n8n has a pretty UI for it. For “weekdays at 8:30 AM Jakarta time”:
Cron expression: 30 8 * * 1-5
Timezone: Asia/Jakarta
The TZ env from the Compose setup sets the default. Per-trigger timezone overrides.
GitHub node
Built-in. Authenticate via Personal Access Token (or GitHub App for production). Operation: “List Pull Requests.” Parameters:
Repository Owner: yourorg
Repository Name: yourrepo
State: closed
Sort: updated
Direction: desc
Per Page: 50
The output: an array of PR objects, one per item. n8n treats each as a separate “item” — downstream nodes run once per item by default.
Filter node
The GitHub node returns all recently-updated closed PRs. We want only PRs merged yesterday. Filter:
Condition:
{{ $json.merged_at }} is not empty
AND
{{ new Date($json.merged_at) >= new Date(new Date().setHours(0,0,0,0) - 24*60*60*1000) }}
That second expression returns true/false from inline JS. Workable but already uglier than the alternative.
The cleaner version: use a Code node instead (next).
Code node
Drop in a Code node. n8n provides $input.all() to get all items, returns whatever you want:
const items = $input.all();
const yesterdayMidnight = new Date();
yesterdayMidnight.setHours(0, 0, 0, 0);
yesterdayMidnight.setDate(yesterdayMidnight.getDate() - 1);
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const merged = items
.map(i => i.json)
.filter(pr => pr.merged_at)
.filter(pr => {
const m = new Date(pr.merged_at);
return m >= yesterdayMidnight && m < todayMidnight;
});
if (merged.length === 0) {
return [{ json: { text: 'No PRs merged yesterday.' } }];
}
const lines = merged.map(pr =>
`• <${pr.html_url}|#${pr.number}> ${pr.title} — _by ${pr.user.login}_`
);
const text = `*Yesterday's merged PRs:*\n${lines.join('\n')}`;
return [{ json: { text } }];
Returns a single item with one field text. Slack-flavored markdown.
Always return an array of { json: {...} } from Code nodes. n8n’s data model is item-based; even single-output Code nodes wrap in the array.
Slack node
Built-in. Authenticate via OAuth (covered in the security post). Operation: “Send Message.” Parameters:
Resource: Message
Operation: Post
Channel: #standup
Text: {{ $json.text }}
The {{ }} is n8n’s expression syntax — references the upstream node’s output. Pulls the text field from the Code node’s single item.
Done. Activate the workflow. Next weekday morning, the bot posts.
Expression syntax
The piece that’s least obvious until you’ve used it. n8n’s expression language is plain JS wrapped in {{ }}. References to data flow through $json, $node, $input, etc.
Common patterns:
{{ $json.field }} current item's field
{{ $json["field with spaces"] }} bracket access for weird keys
{{ $node["GitHub"].json.id }} pull from a specific upstream node
{{ $items("GitHub").length }} count items from a node
{{ new Date().toISOString() }} any JS expression
{{ DateTime.now().toISO() }} luxon DateTime (built-in)
{{ $env.MY_VAR }} environment variable
{{ $credentials.myCreds.apiKey }} (NOT — credentials are sealed)
You write expressions in most node parameter fields. Toggle a field between “fixed” and “expression” with a small icon next to it.
When to drop into Code
The visual model is great for:
- Linear flows with built-in node coverage
- Simple branching (IF on a boolean)
- Field mapping / transformation that fits Set node
It breaks down for:
- Complex transformations (writing 8 nested expressions = use Code)
- Loops over arbitrary data
- Calling multiple sub-APIs in a loop
- Custom error handling per-item
A Code node is a JS escape hatch. Common to have a workflow that’s 3 visual nodes + 1 Code node doing the meat of the logic.
Item model — the one quirk that confuses people
n8n is item-based. A node that receives 10 items runs 10 times by default. A node that “returns an array” actually returns multiple items, one per array element.
// In a Code node: returns 3 items, not 1
return [
{ json: { name: 'a' } },
{ json: { name: 'b' } },
{ json: { name: 'c' } },
];
// Returns 1 item with an array field
return [
{ json: { names: ['a', 'b', 'c'] } },
];
The downstream node treats the first as 3 separate executions, the second as 1 execution with an array to iterate inside expressions. Often the source of “why is this node running 47 times?” surprises.
When you specifically need “run this node once with all upstream items combined,” use the runOnceForAllItems mode in Code nodes.
Common Pitfalls
Forgetting items are arrays. Returning { name: 'x' } from a Code node breaks. Always [{ json: { name: 'x' } }].
Confusing trigger executions with manual ones. Test-button executions don’t always behave identically to triggered ones (e.g., webhook responses differ). Test the real path before assuming it works.
Using cron without TZ. Container time vs your time. Standup ends up posted at 1 AM. Set timezone.
Building 30-node workflows visually. Past 10-15 nodes the visual model becomes unreadable. Refactor: extract sub-workflows.
Expressions that depend on a node’s name. $node["GitHub"] breaks when you rename the GitHub node. Use $node[...].json references sparingly; prefer linear data flow.
Treating Code node as a place for big libs. No npm imports beyond what n8n bundles. For heavy logic, write a real API endpoint and have n8n call it via HTTP.
Wrapping Up
Triggers, nodes, connections, expressions, Code node. Five concepts cover most of n8n. By now you should be able to read the workflows in the rest of May’s posts and not get lost. Monday: connecting n8n to the Jira REST API — the integration that drives most of the rest of the month’s content.