{
  "name": "Back up n8n workflows to GitHub with Vivere alerts",
  "nodes": [
    {
      "id": "3d01db16-cb7b-5c58-a4e0-2a0122f269d1",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        -260
      ],
      "parameters": {
        "width": 560,
        "height": 1080,
        "content": "## Back up every n8n workflow to GitHub, and know when the backup stops\n\n**Who it's for:** anyone running n8n who wants every workflow in a Git repository, and wants to hear about it the night the backup fails rather than the day they need it.\n\n**What it does:** every night it reads all workflows through the n8n API and writes each one to GitHub as `workflows/<name>-<id>.json`. A new workflow is created, a changed one is updated, an unchanged one is left alone, so the commit history shows only real edits. Each run is reported to a [Vivere](https://vivere.dev/docs/n8n) heartbeat monitor, which alerts you (email, Slack, Discord, Telegram, ntfy or webhook) when:\n- the run fails: the n8n API key is wrong, or GitHub refuses a file\n- the workflow doesn't run at all: the instance was down, or someone deactivated it\n\nEach run in Vivere shows how many workflows were backed up and which files changed.\n\n### Setup (about 10 minutes)\n1. Create a GitHub repository for the backups (private is best, with a README so it has a `main` branch) and a fine-grained token with **Contents: Read and write** on it. Add it as a **GitHub API** credential on **Read the file on GitHub** and **Write the file to GitHub**.\n2. In n8n, go to **Settings > n8n API**, create an API key and add it as an **n8n API** credential on **Get all workflows**.\n3. Sign up at [vivere.dev](https://vivere.dev) (the free plan covers 25 monitors), add a **Heartbeat** monitor with period `1 day` and grace `1 hour`, and create an API key under **API keys** for the **Vivere** credential.\n4. In **Config**, paste the monitor's ping URL and set `owner`, `repo`, `branch` and `folder`.\n5. Run it once by hand, check the repository and the run in Vivere, then activate it.\n\n### Customize\n- Change the time on **Every night at 02:00**, and give the monitor the same period.\n- For GitHub Enterprise, set `githubApiUrl` to your server's API address.\n- The commit message is set in **Decide what to write**."
      }
    },
    {
      "id": "67a95521-f5c2-5d94-a766-bffd7992d48f",
      "name": "Note: export",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -160,
        -260
      ],
      "parameters": {
        "color": 7,
        "width": 1040,
        "height": 1080,
        "content": "### 1. Export\n**Ping start** tells Vivere the run began. **Get all workflows** reads every workflow through the n8n API; if that fails (a wrong key or URL), its error output goes straight to **Describe the error**. **Build backup files** gives each workflow a file name from its name and ID, and pretty-prints it so GitHub's diffs are readable."
      }
    },
    {
      "id": "be9977d1-a869-5889-a180-e0e53f30fc07",
      "name": "Note: github",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        900,
        -260
      ],
      "parameters": {
        "color": 7,
        "width": 1380,
        "height": 1080,
        "content": "### 2. Write to GitHub, one file at a time\n**Loop over workflows** handles one file per pass, which keeps well inside GitHub's rate limits.\n**Read the file on GitHub** fetches the current copy (*Never Error* is on, so a 404 for a new file is an answer, not a failure). **Decide what to write** compares it with the export: create it, update it with GitHub's `sha`, or skip it because nothing changed. **Write the file to GitHub** commits only the files that need it, and **Record the result** notes created, updated or failed."
      }
    },
    {
      "id": "3fe8decf-ca28-5bf8-a76b-61a387c0654c",
      "name": "Note: report",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2300,
        -260
      ],
      "parameters": {
        "color": 7,
        "width": 900,
        "height": 1080,
        "content": "### 3. Report to Vivere\n**Count the results** adds up the run. Exactly one closing ping per run:\n- **Ping success** sends the number of workflows as `items` and the list of changed files as the run's log\n- **Ping fail** sends what went wrong, then **Mark the run failed** stops the execution as failed, so your n8n error workflow fires too\n\nIf no ping arrives at all, Vivere alerts when the grace period runs out."
      }
    },
    {
      "id": "12e1cc92-c0ec-5d69-a51e-7df0a0ad3bed",
      "name": "Every night at 02:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -120,
        140
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 2
            }
          ]
        }
      }
    },
    {
      "id": "c547ae7a-416c-5f33-a2cd-fc69b9962685",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        80,
        140
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "83a3df96-37a7-50bd-aa96-4bf1fa74c109",
              "name": "pingUrl",
              "value": "https://vivere.dev/p/YOUR-MONITOR-ID",
              "type": "string"
            },
            {
              "id": "663174ea-34c5-53cb-acb4-f44412ca00e2",
              "name": "githubApiUrl",
              "value": "https://api.github.com",
              "type": "string"
            },
            {
              "id": "7daade14-306e-57eb-a360-d5b9452e873c",
              "name": "owner",
              "value": "your-github-user",
              "type": "string"
            },
            {
              "id": "e448fa22-d093-520b-a457-482d8741948c",
              "name": "repo",
              "value": "n8n-backups",
              "type": "string"
            },
            {
              "id": "67ed2c20-5b4a-539a-a1d5-d730081aeb98",
              "name": "branch",
              "value": "main",
              "type": "string"
            },
            {
              "id": "0e6cbb18-c7f4-58ff-a682-a9d2f1f43677",
              "name": "folder",
              "value": "workflows",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "afb02488-ff04-5dd6-afb7-5492eeadfc62",
      "name": "Ping start",
      "type": "n8n-nodes-vivere.vivere",
      "typeVersion": 1,
      "position": [
        280,
        140
      ],
      "parameters": {
        "resource": "ping",
        "monitor": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Config').first().json.pingUrl }}"
        },
        "operation": "start"
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "df764f28-c406-5159-a09a-0256f3d130ab",
      "name": "Get all workflows",
      "type": "n8n-nodes-base.n8n",
      "typeVersion": 1,
      "position": [
        480,
        140
      ],
      "parameters": {
        "filters": {},
        "requestOptions": {}
      },
      "onError": "continueErrorOutput"
    },
    {
      "id": "35a8b74c-8962-5d22-a32f-e05327465a47",
      "name": "Build backup files",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        120
      ],
      "parameters": {
        "jsCode": "// One file per workflow: <folder>/<name>-<id>.json. The ID keeps two workflows\n// with the same name apart; the name makes the repository readable.\nconst { folder } = $('Config').first().json;\nconst dir = String(folder || '').replace(/^\\/+|\\/+$/g, '');\nconst slug = (s) =>\n  String(s || '')\n    .toLowerCase()\n    .normalize('NFKD')\n    .replace(/[\\u0300-\\u036f]/g, '')\n    .replace(/[^a-z0-9]+/g, '-')\n    .replace(/^-+|-+$/g, '')\n    .slice(0, 60) || 'workflow';\n\nreturn $input.all().map(({ json: wf }) => ({\n  json: {\n    id: wf.id,\n    name: wf.name,\n    path: (dir ? dir + '/' : '') + slug(wf.name) + '-' + wf.id + '.json',\n    content: JSON.stringify(wf, null, 2) + '\\n',\n  },\n}));\n"
      }
    },
    {
      "id": "1e2c82b0-536e-507c-a162-d2b9a519cb0b",
      "name": "Loop over workflows",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        960,
        140
      ],
      "parameters": {
        "options": {}
      }
    },
    {
      "id": "7038e7c1-8ac0-5aa4-aea6-019e34643882",
      "name": "Read the file on GitHub",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1180,
        400
      ],
      "parameters": {
        "url": "={{ $('Config').first().json.githubApiUrl }}/repos/{{ $('Config').first().json.owner }}/{{ $('Config').first().json.repo }}/contents/{{ $json.path }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "ref",
              "value": "={{ $('Config').first().json.branch }}"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/vnd.github+json"
            },
            {
              "name": "X-GitHub-Api-Version",
              "value": "2022-11-28"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "f5a8ec4e-92b4-55d1-a372-976436849f04",
      "name": "Decide what to write",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1400,
        400
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Compares GitHub's copy with the export: create, update (with GitHub's sha) or skip.\nconst { content, ...file } = $('Loop over workflows').item.json;\nconst { branch } = $('Config').first().json;\nconst { statusCode, body } = $json;\nconst request = (sha) => ({\n  message: 'Back up workflow \"' + file.name + '\" (' + file.id + ')',\n  content: Buffer.from(content, 'utf8').toString('base64'),\n  branch,\n  ...(sha ? { sha } : {}),\n});\n\nif (statusCode === 404) {\n  return { json: { ...file, action: 'create', request: request() } };\n}\nif (statusCode !== 200) {\n  const why = (body && body.message) || JSON.stringify(body).slice(0, 200);\n  return { json: { ...file, action: 'skip', result: 'failed', error: 'GitHub answered ' + statusCode + ' reading the file: ' + why } };\n}\nconst current = body.encoding === 'base64' ? Buffer.from(body.content, 'base64').toString('utf8') : null;\nif (current === content) {\n  return { json: { ...file, action: 'skip', result: 'unchanged' } };\n}\nreturn { json: { ...file, action: 'update', request: request(body.sha) } };\n"
      }
    },
    {
      "id": "0242caad-9cb1-50d6-a366-e7db0372c3e9",
      "name": "Needs a write?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1620,
        400
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f5d181bd-a52e-58d8-ac4d-4ffa4d6ac0ab",
              "leftValue": "={{ $json.action }}",
              "rightValue": "skip",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "eccd9cd2-9cb9-5b68-a133-72d1822bda8e",
      "name": "Write the file to GitHub",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1840,
        300
      ],
      "parameters": {
        "method": "PUT",
        "url": "={{ $('Config').first().json.githubApiUrl }}/repos/{{ $('Config').first().json.owner }}/{{ $('Config').first().json.repo }}/contents/{{ $json.path }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/vnd.github+json"
            },
            {
              "name": "X-GitHub-Api-Version",
              "value": "2022-11-28"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.request) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "d150869f-a891-54aa-a659-d90aef69962b",
      "name": "Record the result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2060,
        300
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// 201 is a new file, 200 an updated one; anything else is a failure to report.\nconst { request, ...file } = $('Decide what to write').item.json;\nconst { statusCode, body } = $json;\nif (statusCode === 200 || statusCode === 201) {\n  return { json: { ...file, result: file.action === 'create' ? 'created' : 'updated' } };\n}\nconst why = (body && body.message) || JSON.stringify(body).slice(0, 200);\nreturn { json: { ...file, result: 'failed', error: 'GitHub answered ' + statusCode + ' writing the file: ' + why } };\n"
      }
    },
    {
      "id": "f3719bcc-9b7f-5a4d-a8c5-f76864ca3bc1",
      "name": "Count the results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2360,
        120
      ],
      "parameters": {
        "jsCode": "// Adds up the run for the closing ping.\nconst files = $input.all().map((i) => i.json);\nconst count = (r) => files.filter((f) => f.result === r).length;\nconst failed = files.filter((f) => f.result === 'failed');\nconst summary =\n  files.length + ' workflows: ' + count('created') + ' created, ' + count('updated') + ' updated, ' +\n  count('unchanged') + ' unchanged, ' + failed.length + ' failed';\nconst changes = files\n  .filter((f) => f.result !== 'unchanged')\n  .map((f) => f.result + ': ' + f.path + (f.error ? ' (' + f.error + ')' : ''));\nreturn [{\n  json: {\n    total: files.length,\n    failed: failed.length,\n    log: [summary, ...changes].join('\\n'),\n    message: failed.length ? failed.length + ' of ' + files.length + ' backups failed. ' + failed[0].path + ': ' + failed[0].error : '',\n  },\n}];\n"
      }
    },
    {
      "id": "1d5306e4-05c2-5179-a961-98ea62904680",
      "name": "Any failed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2580,
        120
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "5ed90fef-c6e8-5adf-ac04-ccc7d6c5e916",
              "leftValue": "={{ $json.failed }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "f27608e8-31a5-5469-a5b7-ff3fa46c7547",
      "name": "Ping success",
      "type": "n8n-nodes-vivere.vivere",
      "typeVersion": 1,
      "position": [
        2820,
        20
      ],
      "parameters": {
        "resource": "ping",
        "monitor": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Config').first().json.pingUrl }}"
        },
        "operation": "success",
        "itemCount": "custom",
        "customCount": "={{ $json.total }}",
        "failIfEmpty": true,
        "successOptions": {
          "log": "={{ $json.log }}"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "c0a23ae1-a44f-5cbc-aaa2-af8635181348",
      "name": "Describe the error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2580,
        520
      ],
      "parameters": {
        "jsCode": "// Turns whatever failed into one line for Vivere and the execution log.\nconst e = $json.error ?? $json;\nconst text = (typeof e === 'string' ? e : e.message || e.description || JSON.stringify(e)).replace(/ \\[line \\d+\\]$/, '');\nreturn [{ json: { message: `Could not read workflows from the n8n API: ${text}`.slice(0, 1000) } }];\n"
      }
    },
    {
      "id": "c35a3a8f-5cca-59c8-ac51-8ad220ae1266",
      "name": "Ping fail",
      "type": "n8n-nodes-vivere.vivere",
      "typeVersion": 1,
      "position": [
        2820,
        300
      ],
      "parameters": {
        "resource": "ping",
        "monitor": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Config').first().json.pingUrl }}"
        },
        "operation": "fail",
        "message": "={{ $json.message }}"
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "2f985f2f-053e-54c6-a9ba-bd1a2114ee87",
      "name": "Mark the run failed",
      "type": "n8n-nodes-base.stopAndError",
      "typeVersion": 1,
      "position": [
        3040,
        300
      ],
      "parameters": {
        "errorMessage": "={{ $json.message }}"
      }
    }
  ],
  "pinData": {},
  "connections": {
    "Every night at 02:00": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Ping start",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ping start": {
      "main": [
        [
          {
            "node": "Get all workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get all workflows": {
      "main": [
        [
          {
            "node": "Build backup files",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Describe the error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build backup files": {
      "main": [
        [
          {
            "node": "Loop over workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop over workflows": {
      "main": [
        [
          {
            "node": "Count the results",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Read the file on GitHub",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the file on GitHub": {
      "main": [
        [
          {
            "node": "Decide what to write",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide what to write": {
      "main": [
        [
          {
            "node": "Needs a write?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs a write?": {
      "main": [
        [
          {
            "node": "Write the file to GitHub",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Loop over workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write the file to GitHub": {
      "main": [
        [
          {
            "node": "Record the result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record the result": {
      "main": [
        [
          {
            "node": "Loop over workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Count the results": {
      "main": [
        [
          {
            "node": "Any failed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any failed?": {
      "main": [
        [
          {
            "node": "Ping fail",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ping success",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Describe the error": {
      "main": [
        [
          {
            "node": "Ping fail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ping fail": {
      "main": [
        [
          {
            "node": "Mark the run failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
