Self-hosted n8n audit trail: who changed what, when, and what ran
Short answer: treat audit as three append-only streams — workflow version snapshots, credential/user events, and production execution summaries — written to one Postgres table you control. Self-hosted n8n does not give you a polished compliance console out of the box; you build a thin logger that answers "who changed this workflow?" and "what did production actually send?" without guessing from chat history.
This came up on r/n8n on 6 November 2026 ("How is a audit trail implemented in a self hosted setup", 11 comments): operators need a paper trail for edits and runs, not another dashboard toy.
What "audit trail" means here
| Stream | Question it answers | Source |
|---|---|---|
| Workflow versions | Who edited which workflow, and what did it look like before? | Export / webhook on save, or periodic snapshot |
| Access events | Who logged in, who touched credentials, who activated a workflow? | n8n DB tables + reverse proxy auth logs |
| Execution summaries | What ran in production, with how many items, success or fail? | n8n execution data or a heartbeat node |
You do not need to store full input payloads of every customer email. Store identifiers, counts, hashes, and status. Keep PII out of the audit table unless you have a documented reason and retention policy.
One table that stays readable
CREATE TABLE IF NOT EXISTS n8n_audit_events (
id BIGSERIAL PRIMARY KEY,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL, -- workflow_version | access | execution
actor TEXT, -- user email / system / 'cron'
workflow_id TEXT,
workflow_name TEXT,
resource TEXT, -- credential id, path, etc.
summary TEXT NOT NULL, -- one-line human readable
content_hash TEXT, -- sha256 of workflow JSON or payload fingerprint
meta JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS n8n_audit_seen_at_idx ON n8n_audit_events (seen_at DESC);
CREATE INDEX IF NOT EXISTS n8n_audit_workflow_idx ON n8n_audit_events (workflow_id, seen_at DESC);
Append only. No UPDATE of old rows. If something was wrong, write a compensating event.
Stream 1: workflow versions on every meaningful save
Options that work on self-hosted setups:
-
Scheduled export. Every N minutes, pull workflow JSON from the n8n API, hash it, and insert a
workflow_versionrow only when the hash changed. - Git backup. Many teams already dump workflows to a private repo. Treat each commit as the audit record; still mirror a one-line summary into Postgres so non-git users can query it.
- Manual "publish" workflow. Editors click a button that posts the current workflow JSON to an audit webhook before activating.
The important part is the hash: two saves with the same hash are noise; a new hash is a real change.
summary example:
workflow_version | alice@agency.com | lead-sync | hash=ab12… | nodes=14→16
Stream 2: access and credential events
Self-hosted n8n stores users and credentials in its own database. For a first version:
- Log activations / deactivations of workflows (who turned production on).
- Log credential create/update events if you can read them from the DB or admin API — never log the secret itself, only the credential id and name.
- Put SSO or reverse-proxy auth in front of the editor UI and ship those login logs to the same table as
accessevents.
If you cannot hook the DB yet, start with activations + versions. That already answers most "who broke prod?" questions.
Stream 3: production execution summaries
Reuse the same idea as a silent-failure watchdog: at the end of each critical workflow, POST a small heartbeat:
{
"event_type": "execution",
"workflow_id": "lead-sync",
"workflow_name": "Lead sync",
"actor": "system",
"summary": "execution ok items=42",
"content_hash": "exec:2026-11-06T01:00:00Z:42:ok",
"meta": { "items": 42, "status": "ok", "execution_id": "12345" }
}
Do not dump the full items array into meta. Keep counts, ids, and status.
How to query it when something goes wrong
-- Last 20 changes to one workflow
SELECT seen_at, actor, event_type, summary
FROM n8n_audit_events
WHERE workflow_id = 'lead-sync'
ORDER BY seen_at DESC
LIMIT 20;
-- Everything Alice touched today
SELECT seen_at, event_type, workflow_name, summary
FROM n8n_audit_events
WHERE actor = 'alice@agency.com'
AND seen_at >= date_trunc('day', now())
ORDER BY seen_at;
Common mistakes
- Logging secrets. Credential values, API keys, and raw email bodies do not belong in audit storage.
- Updating instead of appending. An audit row is a fact about the past; correct it with a new row.
- Only logging errors. Successful production runs matter when a customer asks "did you send that on Tuesday?"
- No retention policy. Keep enough history for your contracts (often 90–180 days), then archive or delete on a schedule.
n8n or Make?
If you already self-host n8n, keep the audit writer as a small n8n workflow (webhook + Postgres). If a teammate prefers a visual router for the alert/digest side — daily email of "workflows changed yesterday" — Make can subscribe to the same webhook and format the digest: Make free plan.
What this does not replace
SOC2 evidence packs, legal holds, and full SIEM correlation. This pattern is the minimum useful trail for a small team running self-hosted automation: enough to reconstruct edits and production runs without pretending you bought an enterprise compliance suite.
Disclosure: Some links are affiliate links — I may earn a commission at no extra cost to you.
Top comments (1)
The three-stream split is useful because it separates intent, authority, and execution. I would include the workflow version hash in every production execution record; then an incident review can answer not only what ran, but which exact definition and credential context produced the side effect.