Built this because I kept breaking workflows and had no way to roll back. Now every workflow is versioned in GitHub automatically.
The Problem #
n8n doesn't have built-in version control. You break something, hit save, and the previous working version is gone. There's no undo past the last state.
The naive fix is to export workflows manually. That works once. Then you forget to do it for three weeks and the next time you need a rollback, you're out of luck.
The actual fix: a workflow that backs up all your other workflows to GitHub on demand. Organized by tag, named after the workflow, with GitHub's full commit history as version control.
How It Works #
Manual Trigger
│
v
Config Node repo_owner · repo_name
│
v
Get All Workflows <── n8n API
│
v
Prepare File Paths
│ · skip archived
│ · sanitize name spaces -> _
│ · first tag -> folder
│ · fallback: workflows/
│
v
Loop Over Items ──────────────────────┐
│ │
v │
GET /contents/{path} GitHub API │
│ │
v │
sha = null? │
│ │
+── yes -> PUT (create) │
│ │ │
+── no -> PUT (update + sha) │
│ │
└────────────────┘
Every workflow gets checked against GitHub before writing. If the file already exists, it gets updated in-place with the correct sha. If it's new, it gets created. GitHub keeps the full commit history — every version of every workflow is recoverable.
Repo Structure #
Workflows are organized by their first tag. No tag means they land in workflows/.
your-repo/
│
├── workflows/
│ └── Discord_Bot_Command.json
│
├── CTI/
│ ├── IOC_Enrichment.json
│ └── Threat_Feed_Sync.json
│
├── Homelab/
│ ├── Server_Monitor.json
│ └── Telegram_Bot_Dispatcher.json
│
└── README.md
Filenames are sanitized: spaces become underscores, special characters get stripped, - separators collapse into a single underscore.
Telegram Bot - Subworkflow - Rollo up
→ Telegram_Bot_Subworkflow_Rollo_up.json
The Workflow #
Config Node #
A simple Set node at the start. Two fields:
repo_owner → your GitHub username
repo_name → the target repo
Everything downstream reads from this node, so you only have to change one place if you rename the repo.
Prepare File Paths #
This is where the logic lives. A Code node that runs over all workflows returned by the n8n API:
const items = $input.all();
const config = $('Config').first().json;
const result = [];
for (const item of items) {
const workflow = item.json;
if (workflow.isArchived) continue;
const safeName = workflow.name
.replace(/ - /g, '_')
.replace(/ /g, '_')
.replace(/[^a-zA-Z0-9_]/g, '')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
let folder = 'workflows';
if (workflow.tags && workflow.tags.length > 0) {
const firstTag = workflow.tags[0].name || workflow.tags[0];
folder = firstTag
.replace(/ - /g, '_')
.replace(/ /g, '_')
.replace(/[^a-zA-Z0-9_]/g, '')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
}
result.push({
json: {
workflowId: workflow.id,
workflowName: workflow.name,
safeName,
folder,
filePath: `${folder}/${safeName}.json`,
fileContent: JSON.stringify(workflow, null, 2),
repo_owner: config.repo_owner,
repo_name: config.repo_name
}
});
}
return result;
Archived workflows are skipped. Everything else gets a clean file path and the full workflow JSON as content.
Loop + GitHub API #
After Prepare File Paths, a Split In Batches node (batch size 1) loops over each workflow individually.
For each workflow:
- GET the file from GitHub to check if it exists and grab the
sha - A Code node checks the response —
sha = nullmeans new file, otherwise it exists - PUT to GitHub — create or update depending on the result
The GET and PUT both go directly to the GitHub REST API via HTTP Request nodes. The GitHub node in n8n doesn't expose sha correctly for updates, so raw HTTP is the cleaner approach here.
GET:
GET https://api.github.com/repos/{owner}/{repo}/contents/{path}
Authorization: Bearer <PAT>
PUT (create):
{
"message": "backup: My Workflow (new)",
"content": "<base64 encoded JSON>"
}
PUT (update):
{
"message": "backup: My Workflow (updated)",
"content": "<base64 encoded JSON>",
"sha": "<sha from GET response>"
}
Setup #
1. Create a GitHub repo
Initialize it with a README so it's not empty — the API behaves differently on completely empty repos.
2. Generate a PAT
GitHub → Settings → Developer settings → Personal access tokens → Fine-grained
Required permissions:
Contents → Read and write
Metadata → Read-only (auto)
3. Set up credentials in n8n
Create an HTTP Header Auth credential:
Name: Authorization
Value: Bearer <your token>
Use this for both the GET and PUT nodes.
4. Configure the workflow
Open the Config node, set repo_owner and repo_name. Run it manually once to do the initial backup. After that, run it whenever you want a snapshot.
Notes #
- Only the first tag determines the folder. Multi-tag workflows don't get duplicated.
- Archived workflows are skipped entirely.
- Nothing gets deleted from GitHub. Removing a workflow from n8n doesn't remove it from the repo.
- GitHub's commit history is your version control. Every run that changes a file creates a new commit.
- The workflow backs itself up too.