I built this because I kept changing working automations without keeping a reliable copy. Now every workflow is exported to a private GitHub repository with a readable change history.
The Problem
n8n offers Git-based source control on Business and Enterprise plans. For a self-hosted Community instance, however, there is no equivalent Git workflow built into the editor. Once you overwrite or delete a workflow, recovering an earlier saved definition is not as simple as checking out a commit.
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.
My practical fix is a workflow that exports all other workflows to GitHub on demand. Files are organized by tag and named after the workflow; GitHub then provides the history and diffs.
This is an export of workflow definitions, not a complete n8n instance backup. Credentials, execution history, users, settings, and the database still need their own backup strategy.
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 is updated with its current blob sha. If it is new, it is created without one. GitHub keeps a commit for every changed export, so earlier workflow definitions remain available in the repository history.
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.mdFilenames are sanitized: spaces become underscores, special characters get stripped, - separators collapse into a single underscore.
Telegram Bot - Subworkflow - Rollup
→ Telegram_Bot_Subworkflow_Rollup.jsonThe Workflow
Config Node
A simple Set node at the start. Two fields:
repo_owner → your GitHub username
repo_name → the target repoEverything 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 Loop Over Items node (formerly Split In Batches) with a batch size of 1 processes each workflow serially. Serial writes matter here: parallel updates to the same branch can conflict.
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. Using the REST API makes the create-versus-update behavior explicit and gives direct access to the file sha required for updates.
Configure the GET node to continue on a 404 response. A missing file is expected on the first run and should enter the create branch rather than stop the workflow.
GET:
GET https://api.github.com/repos/{owner}/{repo}/contents/{path}
Authorization: Bearer <PAT>
Accept: application/vnd.github+jsonPUT (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
Make the repository private unless you intentionally want workflow structure, tags, credential references, webhook paths, and other configuration details to be public. Initialize it with a README so the default branch already exists.
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. Restrict the fine-grained token to this repository and grant only Contents: Read and write.
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.
- Workflow exports can contain sensitive metadata even though credential secrets aren't included. Keep the repository private and review the first export before relying on automation.
- Test a restore before calling any backup strategy complete. An export you have never restored is only an assumption.