Configuration

workflow-lint init writes workflow-lint.config.yaml with every block commented. Without a config file, lint uses the workflow-lint:recommended preset.

The config is found the way .gitignore is: the nearest workflow-lint.config.yaml, .yml or .json at or above the working directory. Run the linter from flows/billing/ and it uses the repo's config. The nearest file wins; a parent's is not merged in. --config <path> names a file instead. The CLI, fmt, the GitHub Action and the MCP server all read the same file the same way.

extends:
  - workflow-lint:recommended

settings:
  n8nVersion: 2.38.3

departments:
  hygiene: error

rules:
  naming/external-node-name-format: off
  naming/no-default-node-name: [error, { allowTriggers: false }]

ignore:
  - "**/*.generated.json"
  - path: "workflows/vendor/**"
    reason: "vendor-shipped, regenerated on upgrade"

overrides:
  - files: ["workflows/legacy/**"]
    reason: "pre-standards, frozen until rewritten"
    departments:
      naming: off
  - workflows:
      tags: [experimental]
    rules:
      hygiene/no-placeholder-api-url: off

An explicit rule setting beats its department's setting. Overrides apply last.

Sharing a config

extends takes presets, local files and npm packages, in any mix. Each entry is applied in order and the file's own settings come last.

extends:
  - ../shared/workflow-lint.base.yaml   # relative to this file
  - "@acme/workflow-lint-config"        # an npm package
  - workflow-lint:production            # a preset

A shareable package is a module whose default export is a config object, or a package whose main is a YAML file:

// @acme/workflow-lint-config/index.js
export default {
  plugins: ['@acme/workflow-lint-plugin'],
  departments: { hygiene: 'error' },
};

Maps (settings, departments, rules, fix) merge key by key; lists (ignore, overrides, plugins) append. Relative paths inside an extended file resolve against that file.

Plugins

plugins loads rules written against the API. An entry is a local module, resolved against the config file, or an npm package. Its rules become available under their own ids.

plugins:
  - ./tools/lint-rules/index.mjs
  - "@acme/workflow-lint-plugin"

rules:
  acme/no-http-request: error

A plugin module exports rules and, optionally, presets:

export const rules = [noHttpRequest, approvedCredentialsOnly];
export const presets = {
  'acme:strict': (registry) => ({ rules: { 'acme/no-http-request': 'error' } }),
};

extends: [acme:strict] then works like a built-in preset, and workflow-lint rules lists the plugin's rules with the rest. A rule id that is already registered is an error, not an override.

Presets

PresetWhat it does
workflow-lint:recommendedRuns each rule at its own default level
workflow-lint:productionRaises n8n/valid, hygiene/no-inline-secrets and reliability/http-retry-config to error, raises reliability/webhook-input-contract to warn, and turns on reliability/error-workflow-configured
workflow-lint:strictRaises every rule to error

Pin your n8n version

Set settings.n8nVersion, or pass --n8n-version.

Locking a rule

locked holds a rule or department at a severity that nothing later can change: not rules, not departments, not an override, not an inline directive. A lock in an extended file also beats the extending file's own locked block, so an organisation config stays in force in every repository that extends it.

# @acme/workflow-lint-config
locked:
  hygiene/no-inline-secrets: error
  reliability: warn

A finding of a locked rule that an inline directive names is reported anyway, and counted as blocked in the directive report.

Suppressing a finding

n8n workflows have no comments, so directives go in a node's Notes field. They can also be the first line of a sticky note, which then covers every node inside the sticky's bounds.

workflow-lint-disable naming/no-default-node-name -- renaming next sprint
workflow-lint-disable naming, structure/merge-for-reconvergence
workflow-lint-disable-file *

Every directive is listed in the JSON report under the file's directives, with its reason, how many findings it suppressed and how many it named but could not suppress (blocked, the rule being locked). The summary counts them as used, unused, blocked and ignored. A directive that suppresses nothing is named on stderr: it is stale, or the rule id is mistyped.

--no-inline-config turns directives off for a run. They are still parsed and reported, marked ignored, so the run shows what it refused to honour.

Baseline

A baseline lets you adopt the linter on an existing repository without fixing everything first.

workflow-lint lint --gen-baseline    # accept what is there today
workflow-lint lint                   # from now on, only new findings fail
workflow-lint lint --ignore-baseline # see everything again

The baseline stores a count per file, node and rule. A second occurrence of an accepted rule on a new node is still reported.

Several n8n instances in one repository

When one repository holds the workflows of several instances on different n8n versions, workflow-lint fleet turns a manifest into overrides, so each directory is linted against the version it runs on.

The manifest:

{
  "n8nVersion": "2.38.3",
  "instances": [
    { "name": "dev", "directory": "dev" },
    { "name": "prod", "directory": "prod", "n8nVersion": "2.36.8" },
    { "name": "sandbox", "directory": "sandbox", "versionPolicy": "floating" }
  ]
}

The command:

workflow-lint fleet instances.json >> workflow-lint.config.yaml

What it appends:

overrides:
  - files:
      - dev/**
    reason: targets the n8n running on dev
    settings:
      n8nVersion: 2.38.3
  - files:
      - prod/**
    reason: targets the n8n running on prod
    settings:
      n8nVersion: 2.36.8