AutomationFlowsDevOps › Version Control N8n Workflows in Gitlab with Customer Tag Organization

Version Control N8n Workflows in Gitlab with Customer Tag Organization

ByOmar Kennouche @kennouche-omar on n8n.io

Triggers manually or on schedule (03:00 daily by default) Fetches workflows tagged via n8n API Normalizes workflow names and applies tag convention Prepares JSON in the same structure as an n8n UI export Checks GitLab repository: Create new file if missing Update file if content…

Event trigger★★★★☆ complexity24 nodesn8nGitLab
DevOps Trigger: Event Nodes: 24 Complexity: ★★★★☆ Added:
Version Control N8n Workflows in Gitlab with Customer Tag Organization — n8n workflow card showing n8n, GitLab integration

This workflow corresponds to n8n.io template #8042 — we link there as the canonical source.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "lqAMFCGhYpl6i0Kg",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Client-Organized Workflow Backup (n8n + GitLab)",
  "tags": [
    {
      "id": "U9GFvr98FHfV6LSA",
      "name": "backup-workflows",
      "createdAt": "2025-08-20T16:32:01.267Z",
      "updatedAt": "2025-08-21T19:09:58.119Z"
    }
  ],
  "nodes": [
    {
      "id": "2aaa2725-24b6-46bb-9f9e-153049095183",
      "name": "When clicking \u2018Execute workflow\u2019",
      "type": "n8n-nodes-base.manualTrigger",
      "notes": "Manual trigger for testing the workflow execution.",
      "position": [
        -1136,
        -128
      ],
      "parameters": {},
      "notesInFlow": true,
      "typeVersion": 1
    },
    {
      "id": "a6a535bb-a39c-43e7-8121-16187772dacd",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "notes": "Runs the workflow daily at 03:00 (server time).",
      "position": [
        -1136,
        64
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 3
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 1.2
    },
    {
      "id": "da551fd3-0e10-47f8-8325-5ca0b7bd375d",
      "name": "Prepare Workflow JSON for UI-Compatible Export",
      "type": "n8n-nodes-base.code",
      "notes": "Cleans and normalizes workflow JSON to match n8n export format (only required fields).",
      "position": [
        1056,
        32
      ],
      "parameters": {
        "jsCode": "// ---------------------------------------------------------------------------\n// n8n Code Node - Prepare Workflow JSON for UI-Compatible Export\n// ---------------------------------------------------------------------------\n//\n// GOAL: Produce an output *identical* to the workflow JSON downloaded from\n// the n8n UI \u2192 Same fields, same structure, same order.\n// ---------------------------------------------------------------------------\n\nreturn $input.all().map(item => {\n    const w = item.json;\n\n    // Keep exactly the fields that appear in a native n8n export\n    // And ensure they are in the correct order\n    const cleaned = {\n        name: w.name,                        // Workflow name\n        nodes: w.nodes || [],               // Workflow nodes\n        pinData: w.pinData || {},           // Pinned node data (empty if not set)\n        connections: w.connections || {},   // Workflow connections\n        active: w.active ?? false,          // Default false like exported UI\n        settings: w.settings || {},         // Workflow-level settings\n        versionId: w.versionId || \"\",       // Keep version ID if present\n        meta: w.meta || {},                 // Additional meta info\n        id: w.id,                           // Workflow unique ID\n        tags: w.tags || []                  // Array of tags\n    };\n\n    // Return the cleaned JSON, ready for export or GitLab storage\n    return {\n        json: cleaned\n    };\n});"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "092317f4-3c1e-4c9b-af35-fdeabc9e578f",
      "name": "Clean & Normalize Workflow Name",
      "type": "n8n-nodes-base.code",
      "notes": "Cleans and normalizes workflow name: applies client tag (uppercase) or removes it if missing.",
      "position": [
        800,
        32
      ],
      "parameters": {
        "jsCode": "// ---------------------------------------------------------------------------\n// n8n Code Node - Clean & Normalize Workflow Name\n// ---------------------------------------------------------------------------\n//\n// GOAL: Standardize workflow names by properly formatting [client] tags.\n//\n// - Detects any existing [client] tags in the name.\n// - If a client value exists \u2192 Normalize to `[client : NAME]`.\n// - If the tag exists but is empty \u2192 Remove it completely.\n// - If no tag is present \u2192 Leave the name unchanged.\n//\n// Output structure matches the original item, only the `name` is updated.\n// ---------------------------------------------------------------------------\n\nreturn $input.all().map(item => {\n    const w = item.json;\n\n    // Regex to match patterns like:\n    // [clientXYZ], [client: XYZ], [client : xyz], [client xyz]\n    // [ Client: XYZ] with optional space after [\n    // Also matches empty tags: [client], [client:], [client : ]\n    const clientTag = /\\[\\s*client\\s*:?\\s*([^\\]\\r\\n]*)\\]/i;\n\n    let name = String(w.name || \"\").trim();\n    const match = name.match(clientTag);\n\n    if (match) {\n        // Extract raw client value from the tag\n        const rawClient = match[1] ? match[1].trim() : \"\";\n\n        if (rawClient) {\n            // Normalize client name to uppercase\n            const clientName = rawClient.toUpperCase();\n\n            // Remove the original tag completely\n            name = name.replace(clientTag, \"\").trim();\n\n            // Rebuild the name with the normalized format\n            name = `[client : ${clientName}] ${name}`;\n        } else {\n            // If tag exists but has no value \u2192 remove it entirely\n            name = name.replace(clientTag, \"\").trim();\n        }\n    }\n\n    // Return the updated workflow JSON (only name is modified)\n    return {\n        json: {\n            ...w,\n            name\n        }\n    };\n});"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "4bed4024-392c-4027-907c-0f534dea3466",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        656,
        -640
      ],
      "parameters": {
        "width": 912,
        "height": 912,
        "content": "\n\n## \ud83d\udfe8 Prepare Workflow Data \ud83d\udee0\ufe0f\n\n### \ud83c\udfaf Goal\nClean and prepare workflow data for a consistent and stable GitLab export.  \nThe GitLab file path is always based on the workflow **ID** (rename-proof).  \nThe normalized name is only used for readability and commit messages.\n\n### \ud83d\udd17 Nodes\n- **Normalize Workflow Name** \u2192 standardizes the workflow name and `[client: ...]` tag for readability and logging.  \n- **Prepare Workflow JSON for UI-Compatible Export** \u2192 outputs the workflow JSON in the same format as an n8n UI export (includes `id`, `name`, nodes, tags, etc.).  \n- **Prepare GitLab File Path** \u2192 generates the final storage path for GitLab backups using the workflow **ID** (e.g. `workflow_definitions/<workflowId>.json`).\n\n### \u2705 Best Practices\n- \ud83d\udcc2 Always use the workflow ID for file naming to ensure stability, even if names change.  \n- \ud83d\udc40 Keep JSON human-readable and re-import friendly.  \n\n### \ud83d\udce4 Key Outputs\n- `id` (from exported workflow JSON)  \n- `gitlab_file_path` (constructed path based on workflow ID)  \n- Full workflow JSON ready for GitLab commit\n"
      },
      "typeVersion": 1
    },
    {
      "id": "91eb911b-8a50-4db7-b987-c79ee6cc6e67",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -240,
        -640
      ],
      "parameters": {
        "color": 4,
        "width": 806,
        "height": 912,
        "content": "\n\n## \ud83d\udfe9 Gather Workflows from n8n \ud83d\udcc2\n\n### \ud83c\udfaf Goal  \nIdentify which workflows should be backed up, using tag-based filtering directly in the API call.\n\n### \ud83d\udd17 Node  \nFetch Workflows from n8n \u2192 retrieves only workflows tagged with backup-workflows (filter applied via Tags).\n\n### \u2705 Best practices  \n\ud83c\udff7\ufe0f Tag all critical workflows with backup-workflows to include them in backups.  \n\ud83d\udd0d Audit tags regularly for naming consistency and completeness.  \n\n### \ud83d\udce4 Key outputs  \nFiltered list of workflows selected for backup.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "33a61637-4c62-4349-8a55-115ca7b5adbc",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1264,
        -640
      ],
      "parameters": {
        "color": 6,
        "width": 934,
        "height": 912,
        "content": "\n\n## \ud83d\udfea Start / Trigger & Configure \u26a1\n\n### \ud83c\udfaf Goal\nStart the backup manually or via CRON schedule and initialize GitLab + execution variables.  \n\n### \ud83d\udd17 Nodes\n- **When clicking 'Execute workflow'** \u2192 manual run.  \n- **Schedule Trigger** \u2192 scheduled execution (e.g. `0 3 * * *`).  \n- **Set Global GitLab Variables** \u2192 defines owner, project, storage path, tag filter, execution type & timestamp.  \n\n### \u2705 Best practices\n- \ud83e\uddea Run manually once after workflow changes.  \n- \ud83d\udd10 Keep GitLab credentials encrypted in n8n.  \n\n### \ud83d\udce4 Key outputs\n- `execution_type` = `Manual` | `Scheduled`  \n- `execution_time` = ISO timestamp  \n- [ISO timestamp](https://crontab.guru/examples.html)\n"
      },
      "typeVersion": 1
    },
    {
      "id": "a8d0f8cc-70c5-4713-bb03-8d219feedbae",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1664,
        -1648
      ],
      "parameters": {
        "color": 4,
        "width": 896,
        "height": 1920,
        "content": "\n\n## \ud83d\udfe9 GitLab File Management \ud83d\udcd1\n\n### \ud83c\udfaf Goal\nCompare the current workflow version with the GitLab repository and decide whether to create, update, or skip.  \nThe GitLab file path is always derived from the **workflow ID**, ensuring stability even if names change.\n\n### \ud83d\udd17 Nodes\n- **Fetch Existing File in GitLab** \u2192 tries to read the current file.  \n  - If it exists \u2192 passes to **Compare Workflow with GitLab Version**.  \n  - If it does not exist \u2192 uses the *error output* (On Error \u2192 Continue) and passes directly to **Create New File in GitLab**.  \n- **Compare Workflow with GitLab Version** \u2192 checks for JSON content differences.  \n- **Create New File in GitLab** \u2192 creates the file if not found.  \n- **Update Existing File in GitLab** \u2192 updates only if JSON content differs.\n\n### \u2705 Best practices\n- \ud83d\udcdd Use one commit per workflow for better traceability.  \n- \ud83d\udeab Avoid unnecessary commits with strict JSON comparison.  \n- \ud83d\udd12 Always rely on the workflow **ID** for file paths, never on the workflow name.\n\n### \ud83d\udce4 Key outputs\n- Action performed: `created` | `updated` | `unchanged`\n"
      },
      "typeVersion": 1
    },
    {
      "id": "a0718c04-0981-472d-86f1-d96412dc4b79",
      "name": "Fetch Workflows from n8n",
      "type": "n8n-nodes-base.n8n",
      "notes": "Fetches only workflows tagged \"backup-workflows\" via n8n API.",
      "position": [
        112,
        -32
      ],
      "parameters": {
        "filters": {
          "tags": "={{ $json.tag_backup }}"
        },
        "requestOptions": {}
      },
      "credentials": {
        "n8nApi": {
          "name": "<your credential>"
        }
      },
      "notesInFlow": true,
      "typeVersion": 1
    },
    {
      "id": "79f360ed-86cc-40fe-a105-b3ef23120e97",
      "name": "Fetch Existing File from GitLab",
      "type": "n8n-nodes-base.gitlab",
      "notes": "Fetches the existing workflow backup file from GitLab.",
      "onError": "continueErrorOutput",
      "position": [
        1824,
        -32
      ],
      "parameters": {
        "owner": "={{ $('Set Global GitLab Variables').item.json.gitlab_owner }}",
        "filePath": "={{ $json.gitlab_file_path }}",
        "resource": "file",
        "operation": "get",
        "repository": "={{ $('Set Global GitLab Variables').item.json.gitlab_project }}",
        "asBinaryProperty": false,
        "additionalParameters": {
          "reference": "={{ $('Set Global GitLab Variables').item.json.gitlab_branch }}"
        }
      },
      "credentials": {
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "executeOnce": false,
      "notesInFlow": true,
      "retryOnFail": false,
      "typeVersion": 1,
      "alwaysOutputData": false
    },
    {
      "id": "15cd412a-dd8f-4b71-ab54-849a6a535af8",
      "name": "Update Existing File in GitLab",
      "type": "n8n-nodes-base.gitlab",
      "notes": "Updates the existing workflow backup file in GitLab with the latest JSON export.",
      "position": [
        2320,
        -208
      ],
      "parameters": {
        "owner": "={{ $('Set Global GitLab Variables').item.json.gitlab_owner }}",
        "branch": "={{ $(\"Set Global GitLab Variables\").item.json.gitlab_branch }}",
        "filePath": "={{ $json.file_path || $(\"Prepare GitLab File Path\").item.json.gitlab_file_path }}",
        "resource": "file",
        "operation": "edit",
        "repository": "={{ $('Set Global GitLab Variables').item.json.gitlab_project }}",
        "fileContent": "={{ JSON.stringify($(\"Prepare Workflow JSON for UI-Compatible Export\").item.json, null, 2) }}",
        "commitMessage": "={{ \"Update backup for workflow: \" \n    + $(\"Clean & Normalize Workflow Name\").item.json.name \n    + \" (\" \n    + ($json.file_path || $(\"Prepare GitLab File Path\").item.json.gitlab_file_path) \n    + \")\" \n}}"
      },
      "credentials": {
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "notesInFlow": true,
      "typeVersion": 1
    },
    {
      "id": "da9f550c-bd0f-4bc2-b221-cab11a8d7be6",
      "name": "Create New File in GitLab",
      "type": "n8n-nodes-base.gitlab",
      "notes": "Creates a new workflow backup file in GitLab if it does not already exist.",
      "position": [
        2096,
        64
      ],
      "parameters": {
        "owner": "={{ $('Set Global GitLab Variables').item.json.gitlab_owner }}",
        "branch": "={{ $('Set Global GitLab Variables').item.json.gitlab_branch }}",
        "filePath": "={{ $json.gitlab_file_path }}",
        "resource": "file",
        "repository": "={{ $('Set Global GitLab Variables').item.json.gitlab_project }}",
        "fileContent": "={{ JSON.stringify($(\"Prepare Workflow JSON for UI-Compatible Export\").item.json, null, 2) }}",
        "commitMessage": "={{ \"Add backup for workflow: \" \n   + $(\"Clean & Normalize Workflow Name\").item.json.name \n   + \" (\" \n   + ($json.gitlab_file_path || $(\"Prepare GitLab File Path\").item.json.gitlab_file_path) \n   + \")\" }}"
      },
      "credentials": {
        "gitlabApi": {
          "name": "<your credential>"
        }
      },
      "notesInFlow": true,
      "typeVersion": 1
    },
    {
      "id": "5ddc1294-9b87-42d8-93b8-f5e695dbf8bb",
      "name": "Normalize Backup Output",
      "type": "n8n-nodes-base.set",
      "notes": "Normalizes backup output: adds GitLab path, branch, owner, project, execution type & timestamp.",
      "position": [
        3392,
        -16
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "46c0d9f1-dfe1-4ada-b7e5-0148badac474",
              "name": "status",
              "type": "string",
              "value": "={{ $json.status }}"
            },
            {
              "id": "5810a928-6341-4600-8d8a-147c7b003c79",
              "name": "workflow_name",
              "type": "string",
              "value": "={{ $(\"Prepare Workflow JSON for UI-Compatible Export\").item.json.name }}"
            },
            {
              "id": "d25b1d07-589c-4b02-ae3e-90d3e292dec8",
              "name": "file_path",
              "type": "string",
              "value": "={{ $(\"Prepare GitLab File Path\").item.json.gitlab_file_path }}"
            },
            {
              "id": "226b5ec1-c8e1-43e9-b621-44e8e87328da",
              "name": "branch",
              "type": "string",
              "value": "={{ $(\"Set Global GitLab Variables\").item.json.gitlab_branch }}"
            },
            {
              "id": "596584fb-4c78-4863-909c-728409de7e48",
              "name": "gitlab_owner",
              "type": "string",
              "value": "={{ $(\"Set Global GitLab Variables\").item.json.gitlab_owner }}"
            },
            {
              "id": "c8db01a0-5ca5-4bff-be3f-fe9fe0f5fc7b",
              "name": "gitlab_project",
              "type": "string",
              "value": "={{ $(\"Set Global GitLab Variables\").item.json.gitlab_project }}"
            },
            {
              "id": "c8d1166c-c5b0-44a3-9740-6ae0c5b077b8",
              "name": "execution_type",
              "type": "string",
              "value": "={{ $(\"Set Global GitLab Variables\").item.json.execution_type }}"
            },
            {
              "id": "5ef4679e-0125-419b-b154-b2562198c616",
              "name": "execution_time",
              "type": "string",
              "value": "={{ $now }}"
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 3.4
    },
    {
      "id": "9939f6ff-8915-47f3-8a87-6ff204fd2c0d",
      "name": "Set Global GitLab Variables",
      "type": "n8n-nodes-base.set",
      "notes": "Defines global GitLab variables (owner, project, branch, paths, tags, execution type) for reuse across the workflow.",
      "position": [
        -640,
        -32
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "d3a64cab-d823-4bfb-9ff8-3f00f1bd0942",
              "name": "gitlab_owner",
              "type": "string",
              "value": "n8n-ainexusone"
            },
            {
              "id": "c13bd+1234567890e-94394bd818d6",
              "name": "gitlab_project",
              "type": "string",
              "value": "n8n_workflow_backups"
            },
            {
              "id": "2c58ec25-1e33-406c-821a-62eff539f2db",
              "name": "gitlab_workflow_path",
              "type": "string",
              "value": "workflow_definitions"
            },
            {
              "id": "1517ca87-dba2-4411-af85-8d2c91e7aa42",
              "name": "gitlab_branch",
              "type": "string",
              "value": "main"
            },
            {
              "id": "39a0387d-8195-4c49-9831-c87f77758cdd",
              "name": "tag_backup",
              "type": "string",
              "value": "backup-workflows"
            },
            {
              "id": "3357bb93-0e45-4a8d-aced-ebe5b7e93ef8",
              "name": "execution_type",
              "type": "string",
              "value": "={{ ( $('Schedule Trigger').isExecuted) ? 'Scheduled' : 'Manual' }}"
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 3.4
    },
    {
      "id": "2ea3ccb3-8123-4f49-82be-c4b90e0c5f20",
      "name": "Prepare GitLab File Path",
      "type": "n8n-nodes-base.code",
      "notes": "Builds a normalized GitLab file path for the workflow backup (workflowId + .json)",
      "position": [
        1312,
        32
      ],
      "parameters": {
        "jsCode": "// ---------------------------------------------------------------------------\n// n8n Code Node - Prepare GitLab File Path (Dedicated Node)\n// ---------------------------------------------------------------------------\n//\n// GOAL: Generate the GitLab storage path for each workflow without polluting\n//       the workflow JSON that will be versioned. File name is fixed on ID\n//       to avoid duplication when the workflow name changes.\n// ---------------------------------------------------------------------------\n\nreturn $input.all().map(item => {\n    const w = item.json; // Current workflow data\n\n    // --------------------------------------------------------\n    // Helper: normalize accents & slugify safely\n    // --------------------------------------------------------\n    const toSlug = (s) => String(s || '')\n        .normalize('NFKD')                // decompose accented characters\n        .replace(/[\\u0300-\\u036f]/g, '')  // remove diacritics\n        .toLowerCase()\n        .replace(/[^a-z0-9]+/g, '-')      // replace invalid chars with hyphens\n        .replace(/^-+|-+$/g, '');         // trim hyphens at start/end\n\n    // --------------------------------------------------------\n    // 1. Extract and normalize the client slug if tag exists\n    // --------------------------------------------------------\n    const clientTag = /\\[client\\s*:?\\s*([^\\]\\r\\n]*)\\]/i;\n    const match = (w.name || '').match(clientTag);\n\n    let clientSlug = \"unassigned\"; // Default if no [client: ...] tag is found\n    if (match && match[1].trim()) {\n        clientSlug = toSlug(match[1].trim()) || \"unassigned\";\n    }\n\n    // --------------------------------------------------------\n    // 2. Build the GitLab storage path (ID-based, rename-proof)\n    // --------------------------------------------------------\n    const basePath = $('Set Global GitLab Variables').first().json.gitlab_workflow_path; // From declared variables\n    const filePath = `${basePath}/${clientSlug}/${w.id}.json`;\n\n    // --------------------------------------------------------\n    // 3. Return ONLY the GitLab file path\n    // --------------------------------------------------------\n    return {\n        json: {\n            gitlab_file_path: filePath\n        }\n    };\n});\n"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "9f4fd524-5c9b-4ba6-bef2-a6da86f64c7d",
      "name": "Compare Workflow with GitLab Version",
      "type": "n8n-nodes-base.if",
      "notes": "Compares exported workflow JSON with the GitLab version to detect changes.",
      "position": [
        2096,
        -128
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "2e7b9fc6-cf3e-4f3c-b8be-a55221f5d1f4",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ JSON.stringify($(\"Prepare Workflow JSON for UI-Compatible Export\").item.json) }}",
              "rightValue": "={{ JSON.stringify(JSON.parse($json.content.base64Decode().trim())) }}"
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 2.2
    },
    {
      "id": "71fc8901-e3ea-4540-9384-51525834964b",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2656,
        -1648
      ],
      "parameters": {
        "color": 5,
        "width": 1280,
        "height": 1920,
        "content": "\n\n## \ud83d\udfe6 Output & Logging \ud83d\udcca\n\n### \ud83c\udfaf Goal\nStandardize results for reporting and monitoring.  \n\n### \ud83d\udd17 Nodes\n- **Mark as Created** \u2192 enriches each workflow output with `status = created`.  \n- **Mark as Updated** \u2192 enriches each workflow output with `status = updated`.  \n- **Mark as Unchanged** \u2192 enriches each workflow output with `status = unchanged`.  \n- **Merge Backup Results** \u2192 combines all outputs (`created`, `updated`, `unchanged`) into a single flow.  \n- **Normalize Backup Output** \u2192 consolidates merged data into a standardized schema for logging and reporting.  \n- **Summarize Backup Results** \u2192 aggregates counts per status and adds execution context (manual/scheduled + timestamp).  \n\n### \u2705 Best practices\n- \ud83d\udce2 Use consistent logging for dashboards, Slack alerts, or monitoring systems.  \n- \u2705 Ensure all cases (`created`, `updated`, `unchanged`) are tracked.  \n- \ud83e\udde9 Keep outputs normalized to simplify downstream processing.  \n- \ud83d\udcca Provide a final recap (`created`, `updated`, `unchanged`, `total`) for monitoring and auditing.  \n\n### \ud83d\udce4 Key outputs\n- `status` = `created` | `updated` | `unchanged`  \n- `workflow_name`  \n- `file_path`  \n- `execution_type`, `execution_time`  \n- `recap` = `{ created, updated, unchanged, total }`\n"
      },
      "typeVersion": 1
    },
    {
      "id": "fcc49db9-286c-4f06-8e4e-dc1f11a0488c",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1264,
        -1648
      ],
      "parameters": {
        "color": 3,
        "width": 1328,
        "height": 912,
        "content": "\n\n# \ud83d\udcd8 n8n \u2192 GitLab Backup (with `[client]`) \u2014 Cheat Sheet\n\n## 1\ufe0f\u20e3 Purpose\n- Versioned history, centralized repo, internal vs client separation.\n\n## 2\ufe0f\u20e3 Client Management\n- Default path: `workflow_definitions/<file>.json`  \n- If name contains `[client: Acme]` \u2192 `workflow_definitions/acme/<file>.json`\n\n## 3\ufe0f\u20e3 Setup\n- Create tag: **`backup-workflows`**  \n- Tag target workflows  \n- Triggers:  \n  - \u26a1 Manual  \n  - \u23f0 Schedule (daily 03:00) \u2014 or cron example: `30 21 * * 6` (Sat 21:30)\n\n## 4\ufe0f\u20e3 Initialization (Globals)\n- GitLab owner/project  \n- Root path: `workflow_definitions/`  \n- Tag filter: `backup-workflows`  \n- `execution_type` (Manual | Scheduled), `execution_time` (ISO)\n\n## 5\ufe0f\u20e3 Selection\n- Fetch Workflows \u2192 retrieve only workflows tagged `backup-workflows`\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "178c79f8-345e-4d33-a8aa-4eb31da592b7",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        64,
        -1648
      ],
      "parameters": {
        "color": 3,
        "width": 1504,
        "height": 912,
        "content": "\n\n## 6\ufe0f\u20e3 Name Normalization\n- Extract `[client: Name]` tag \u2192 route file to subfolder `<client>/` (or `unassigned/` if none).  \n- File name is fixed to `<workflowId>.json` to preserve history across renames.  \n- Generate a `display_slug` (optional, from the workflow name) for documentation or index files.\n\n## 7\ufe0f\u20e3 GitLab Verification\n- Check if file already exists in GitLab.\n  - \ud83c\udd95 No \u2192 **Create** (status = `created`, via *Mark as Created*).\n  - \ud83d\udd04 Yes + content differs \u2192 **Update** (status = `updated`, via *Mark as Updated*).\n  - \u23ed\ufe0f Yes + unchanged \u2192 **Skip** (status = `unchanged`, via *Mark as Unchanged*).\n\n## 8\ufe0f\u20e3 Backup Actions\n- **Create New File(s)** \u2192 first commit of a workflow.  \n- **Update Existing File(s)** \u2192 commit only if JSON content changed.  \n- **Skip Unchanged File(s)** \u2192 prevent unnecessary commits.  \n- Commit message: \"Add/Update backup for workflow: <name> (<path>)\".\n\n## 9\ufe0f\u20e3 Best Practices\n- \ud83d\udd10 Limit GitLab token scope (repo-only).  \n- \ud83c\udff7\ufe0f Standardize client names (`acme`, not `Acme Corp` vs `ACME`).  \n- \ud83d\udcca Add a final recap (`created`, `updated`, `unchanged`).  \n- \ud83d\udee0\ufe0f Compare JSON **object-to-object** (avoid false positives due to indentation or line breaks).  \n\n## \ud83d\udd1f Expected Results\n- Only workflows tagged with `backup-workflows` are auto-saved.  \n- Internal workflows \u2192 `workflow_definitions/`.  \n- Client workflows \u2192 `workflow_definitions/<client>/`.  \n- Clean, structured Git history with meaningful commits and standardized statuses. "
      },
      "typeVersion": 1
    },
    {
      "id": "75b2037f-bad5-4988-9f82-73665e4ae0eb",
      "name": "Mark as Created",
      "type": "n8n-nodes-base.set",
      "notes": "Tags workflow as \"created\" (new file added in GitLab).",
      "position": [
        2928,
        64
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8e6cb925-589a-4daa-bf5c-73a51092ae9e",
              "name": "status",
              "type": "string",
              "value": "created"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "e995304e-5611-4905-9a33-b6fdd70d3655",
      "name": "Mark as Updated",
      "type": "n8n-nodes-base.set",
      "notes": "Tags workflow as \"updated\" after backup comparison.",
      "position": [
        2928,
        -96
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8e6cb925-589a-4daa-bf5c-73a51092ae9e",
              "name": "status",
              "type": "string",
              "value": "updated"
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 3.4
    },
    {
      "id": "69533d20-2a48-4cb4-810d-e65c2a4aa71c",
      "name": "Mark as Unchanged",
      "type": "n8n-nodes-base.set",
      "notes": "Tags workflow as \"unchanged\" (no differences found).",
      "position": [
        2768,
        -16
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8e6cb925-589a-4daa-bf5c-73a51092ae9e",
              "name": "status",
              "type": "string",
              "value": "unchanged"
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 3.4
    },
    {
      "id": "7af2d653-0358-4b5a-847c-8b1851aa2721",
      "name": "Summarize Backup Results",
      "type": "n8n-nodes-base.code",
      "notes": "Summarizes backup results: counts created/updated/unchanged workflows and adds execution metadata.",
      "position": [
        3712,
        -16
      ],
      "parameters": {
        "jsCode": "// n8n Code Node \u2014 Summarize Backup Results\n// ----------------------------------------\n\nconst items = $input.all();\n\n// Init counters\nlet recap = {\n  created: 0,\n  updated: 0,\n  unchanged: 0,\n  total: items.length,\n};\n\nfor (const item of items) {\n  const status = item.json.status;\n  if (status && recap.hasOwnProperty(status)) {\n    recap[status]++;\n  }\n}\n\n// Build output\nreturn [\n  {\n    json: {\n      execution_type: items[0]?.json.execution_type || \"unknown\",\n      execution_time: items[0]?.json.execution_time || new Date().toISOString(),\n      recap,\n    },\n  },\n];"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "90c21f27-3af6-416f-978e-2ff38c300c7a",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "notes": "Merges outputs from \"Mark as Updated/Unchanged/Created\" into a single stream.\n",
      "position": [
        3168,
        -32
      ],
      "parameters": {
        "numberInputs": 3
      },
      "notesInFlow": true,
      "typeVersion": 3.2
    }
  ],
  "active": true,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "35bc096b-29a7-461f-9bc7-23fd23a9aa5c",
  "connections": {
    "Merge": {
      "main": [
        [
          {
            "node": "Normalize Backup Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as Created": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Mark as Updated": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Set Global GitLab Variables",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as Unchanged": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Normalize Backup Output": {
      "main": [
        [
          {
            "node": "Summarize Backup Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Workflows from n8n": {
      "main": [
        [
          {
            "node": "Clean & Normalize Workflow Name",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare GitLab File Path": {
      "main": [
        [
          {
            "node": "Fetch Existing File from GitLab",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create New File in GitLab": {
      "main": [
        [
          {
            "node": "Mark as Created",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Global GitLab Variables": {
      "main": [
        [
          {
            "node": "Fetch Workflows from n8n",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Existing File in GitLab": {
      "main": [
        [
          {
            "node": "Mark as Updated",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clean & Normalize Workflow Name": {
      "main": [
        [
          {
            "node": "Prepare Workflow JSON for UI-Compatible Export",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Existing File from GitLab": {
      "main": [
        [
          {
            "node": "Compare Workflow with GitLab Version",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create New File in GitLab",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare Workflow with GitLab Version": {
      "main": [
        [
          {
            "node": "Update Existing File in GitLab",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark as Unchanged",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When clicking \u2018Execute workflow\u2019": {
      "main": [
        [
          {
            "node": "Set Global GitLab Variables",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Workflow JSON for UI-Compatible Export": {
      "main": [
        [
          {
            "node": "Prepare GitLab File Path",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Triggers manually or on schedule (03:00 daily by default) Fetches workflows tagged via n8n API Normalizes workflow names and applies tag convention Prepares JSON in the same structure as an n8n UI export Checks GitLab repository: Create new file if missing Update file if content…

Source: https://n8n.io/workflows/8042/ — original creator credit. Request a take-down →

More DevOps workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

DevOps

Gitlab Code. Uses manualTrigger, noOp, splitInBatches, gitlab. Event-driven trigger; 21 nodes.

GitLab, n8n
DevOps

This template is inspired by Save your workflows into a GitHub repository by hikerspath and Back Up Your n8n Workflows To Github by jon-n8n.

GitLab, n8n
DevOps

Gitlab Filter. Uses manualTrigger, n8n, gitlab, stickyNote. Event-driven trigger; 16 nodes.

n8n, GitLab
DevOps

Fetches workflow definitions from within n8n, selecting only the ones that have one or more (configurable) assigned tags and then: Derives a suitable backup filename by reducing the workflow name to a

n8n, GitLab
DevOps

This solution ensures the secure backup and version control of your self-hosted n8n workflows by storing them in a GitLab repository. It compares current workflows with their GitLab counterparts, upda

n8n, GitLab, Email Send