Back to Article List

CI/CD for n8n: Deploy workflows with GitHub Actions

CI/CD for n8n: Deploy workflows with GitHub Actions

How do your workflows get from the editor to production right now? For most self-hosted n8n setups I've looked at, the answer is "the editor is production", and the change process is someone clicking Publish and hoping. That works up to about a dozen workflows and one person. Past that you want the same thing you'd want for any code: a file in a repository, a diff someone reads, a job that puts it on the server and a check that it runs. This article is the pipeline part of that, end to end, on GitHub Actions against an n8n 2.x instance on a VPS. Environments, promotion between dev and prod and the built-in Git feature on the paid tiers are a separate topic, handled in the n8n version control and environments guide, so they get one mention here and no more. What's here is the file, the job and the check.

Pipeline overview

The repo holds one JSON file per workflow under workflows/, exported by the n8n CLI. A pull request changes a file. On merge to main, an Actions job validates every file, waits for a reviewer to approve the production environment, then for each changed workflow sends a PUT to the n8n public REST API on the prod instance with the workflow body. n8n re-publishes it. A last job fires a test webhook and checks the response. Total runtime for our 31 workflows is around 40 seconds, most of it the approval wait.

There's a second route to the server, over SSH with the CLI instead of the API, and it's covered lower down for people who keep the public API disabled. The Actions file at the end uses the API.

Export n8n workflows to the repository

On the instance where you build (dev, or prod itself if you only have one), export all workflows as separate pretty-printed files and copy them out of the container:

docker compose exec -u node n8n n8n export:workflow --backup --output=/home/node/.n8n/export/
docker cp "$(docker compose ps -q n8n)":/home/node/.n8n/export/. ./workflows/

--backup is the flag that gives you one file per workflow with the JSON formatted, which is what makes the diffs readable. Each file carries id, name, nodes, connections, settings, versionId, timestamps, tags and any pinned data. Commit the lot; the pipeline strips what the API won't accept at deploy time. The n8n CLI reference has the other export forms (single id, a specific version, published version only) if you want a narrower export. I only ever use --backup.

The file names are the workflow ids, which is unhelpful in a PR list. I rename them to <id>-<slug>.json with a two-line script after export, and the deploy loop reads the id from inside the file, so the name is cosmetic. Pinned data (pinData) is test fixtures from the editor and I strip it before committing; it's large, it's noisy in diffs and it has no place on prod.

Deploy through the n8n public REST API

API key and N8N_PUBLIC_API_DISABLED

The public API lives at https://<your-instance>/api/v1 and authenticates with a header, X-N8N-API-KEY. Create a key on the prod instance under Settings, n8n API, with a label like github-deploy and an expiry; the API authentication page shows the flow. On community and Business the key inherits the full access of the user who created it (scoped keys are an Enterprise feature), so create it from a dedicated admin account, not your own. Store it as a GitHub Actions secret named N8N_API_KEY along with N8N_URL.

If you followed a hardening guide the API may be off. N8N_PUBLIC_API_DISABLED=true returns 404 for every call, and the deploy will fail with nothing more helpful than that. Set it to false on prod, or use the SSH route. The Swagger UI at /api/v1/docs can stay disabled with N8N_PUBLIC_API_SWAGGERUI_DISABLED=true; the API works without it. The broader list of what to lock down is in the n8n security checklist for a VPS, and this is the one item on it I relax. An expiring key on a dedicated user is the compromise.

Update a workflow with PUT /api/v1/workflows/{id}

The update call takes exactly four fields: name, nodes, connections and settings. Everything else in the exported file (id, active, versionId, createdAt, updatedAt, tags, triggerCount) is read-only and gets the request rejected. jq does the stripping:

ID=$(jq -r '.id' workflows/abc123-invoice-sync.json)
jq '{name, nodes, connections, settings}' workflows/abc123-invoice-sync.json \
  | curl -sS --fail-with-body -X PUT "$N8N_URL/api/v1/workflows/$ID" \
      -H "X-N8N-API-KEY: $N8N_API_KEY" \
      -H "Content-Type: application/json" \
      --data-binary @-

Publishing is where 2.x differs from older guides. The update endpoint has a publishIfActive query parameter that defaults to true, so a workflow that is already published on prod is re-published with the new version as part of the same call; the n8n API workflow reference documents the parameter along with the rest of the workflow endpoints. You don't unpublish, update and publish in three steps, and there is no window where the webhook is unregistered. If you want to push a draft without going live, add ?publishIfActive=false. The response body is the updated workflow, including its new versionId, if you want to log it.

Create a new workflow with POST and publish it

A workflow that doesn't exist on prod yet goes through POST /api/v1/workflows with the same four-field body. The response contains the id prod assigned, which is not the id from your dev export. The new workflow comes in unpublished; publish it with the activate endpoint, which kept its 1.x name:

NEW_ID=$(jq '{name, nodes, connections, settings}' workflows/new-workflow.json \
  | curl -sS --fail-with-body -X POST "$N8N_URL/api/v1/workflows" \
      -H "X-N8N-API-KEY: $N8N_API_KEY" -H "Content-Type: application/json" \
      --data-binary @- | jq -r '.id')
curl -sS --fail-with-body -X POST "$N8N_URL/api/v1/workflows/$NEW_ID/activate" \
  -H "X-N8N-API-KEY: $N8N_API_KEY"

Because ids differ between instances, the pipeline needs a map from repo id to prod id. Mine is a deploy/ids.json file, {"<dev id>": "<prod id>"}, updated by hand the first time a workflow is created on prod and committed with the next change. It's a small chore and I've not found a cleaner way that doesn't involve the pipeline committing to its own repo.

Deploy over SSH with the n8n CLI

If the public API stays off, the runner connects to the VPS over SSH, copies the files in and imports them with the CLI. Add a deploy key pair: private key in a secret named DEPLOY_SSH_KEY, public key in ~/.ssh/authorized_keys of a user that can run docker compose on the server. The steps on the server:

docker cp ./workflows/. "$(docker compose ps -q n8n)":/home/node/.n8n/import/
docker compose exec -T -u node n8n n8n import:workflow --separate --input=/home/node/.n8n/import/
docker compose exec -T -u node n8n n8n publish:workflow --id=abc123

publish:workflow takes one id at a time and there is no --all, so the loop runs it per workflow. The command it replaces, update:workflow --id=... --active=true, still exists in 2.38 but is deprecated and scheduled for removal, so don't build a new pipeline on it. The trade-off against the API route: the CLI import keeps the ids from the file, so the id map goes away, but you're giving a CI runner shell access to the production server, which is a bigger key than an API token. I use the API for prod and the SSH route for a staging box where I care less.

Credentials stay out of the repository

The exported workflow JSON references credentials by id and name inside each node, and contains no secrets. When you PUT that JSON to prod, n8n resolves the reference against the credentials that exist on prod. So a credential named Stripe live with the same id needs to exist there already, created once by hand in the prod UI, and any workflow that uses it will pick it up. Keep credential names identical across instances and you'll rarely hit the case where a node arrives pointing at nothing. When you do hit it, the workflow saves but the node shows the "workflow has issues" warning until someone opens it and picks the credential, and the smoke test at the end catches that.

n8n export:credentials --backup does exist and produces encrypted files, and they only decrypt on an instance with the same N8N_ENCRYPTION_KEY. I don't put those in the repo. They belong in the n8n backup set, encrypted at rest and off the server, not next to workflow diffs that half the team reads. Names and types are all the repo needs.

Anything the pipeline itself needs (the API key, the instance URL, the SSH key, a webhook test secret) is a GitHub Actions secret, referenced as ${{ secrets.NAME }} and never echoed. GitHub masks them in logs, but a set -x in a shell step will still leak a URL with a token in it so keep the token in a header not the URL.

The complete GitHub Actions workflow file

.github/workflows/deploy-n8n.yml. It validates on every PR, deploys on merge to main after a reviewer approves the production environment, and smoke tests afterwards:

name: Deploy n8n workflows

on:
  pull_request:
    paths: ["workflows/**"]
  push:
    branches: [main]
    paths: ["workflows/**"]
  workflow_dispatch:

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: Every file is valid JSON with the four required fields
        run: |
          set -euo pipefail
          for f in workflows/*.json; do
            jq -e '.name and .nodes and .connections and .settings' "$f" > /dev/null \
              || { echo "$f is missing a required field"; exit 1; }
          done
      - name: No pinned data committed
        run: |
          set -euo pipefail
          for f in workflows/*.json; do
            [ "$(jq '.pinData // {} | length' "$f")" = "0" ] \
              || { echo "$f has pinData, strip it before committing"; exit 1; }
          done

  deploy:
    if: github.event_name != 'pull_request'
    needs: validate
    runs-on: ubuntu-latest
    environment: production
    env:
      N8N_URL: ${{ secrets.N8N_URL }}
      N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 2
      - name: Deploy changed workflows
        run: |
          set -euo pipefail
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            FILES=$(ls workflows/*.json)
          else
            FILES=$(git diff --name-only HEAD~1 HEAD -- 'workflows/*.json' || true)
          fi
          for f in $FILES; do
            [ -f "$f" ] || continue
            SRC_ID=$(jq -r '.id' "$f")
            PROD_ID=$(jq -r --arg k "$SRC_ID" '.[$k] // empty' deploy/ids.json)
            BODY=$(jq '{name, nodes, connections, settings}' "$f")
            if [ -n "$PROD_ID" ]; then
              echo "PUT $f -> $PROD_ID"
              curl -sS --fail-with-body -X PUT "$N8N_URL/api/v1/workflows/$PROD_ID" \
                -H "X-N8N-API-KEY: $N8N_API_KEY" -H "Content-Type: application/json" \
                --data-binary "$BODY" > /dev/null
            else
              echo "POST $f (new on prod)"
              NEW_ID=$(curl -sS --fail-with-body -X POST "$N8N_URL/api/v1/workflows" \
                -H "X-N8N-API-KEY: $N8N_API_KEY" -H "Content-Type: application/json" \
                --data-binary "$BODY" | jq -r '.id')
              curl -sS --fail-with-body -X POST "$N8N_URL/api/v1/workflows/$NEW_ID/activate" \
                -H "X-N8N-API-KEY: $N8N_API_KEY" > /dev/null
              echo "::notice::add \"$SRC_ID\": \"$NEW_ID\" to deploy/ids.json"
            fi
          done

  smoke-test:
    needs: deploy
    runs-on: ubuntu-latest
    env:
      N8N_URL: ${{ secrets.N8N_URL }}
      N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
    steps:
      - name: Fire the canary webhook and check the reply
        run: |
          set -euo pipefail
          OUT=$(curl -sS --fail-with-body -X POST "$N8N_URL/webhook/ci-canary" \
            -H "X-Canary-Token: ${{ secrets.CANARY_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{"source":"github-actions","run":"${{ github.run_id }}"}')
          echo "$OUT" | jq -e '.ok == true' > /dev/null
      - name: No errored executions in the last two minutes
        run: |
          sleep 30
          SINCE=$(date -u -d '-2 minutes' +%Y-%m-%dT%H:%M:%S.000Z)
          COUNT=$(curl -sS "$N8N_URL/api/v1/executions?status=error&limit=50" \
            -H "X-N8N-API-KEY: $N8N_API_KEY" \
            | jq --arg since "$SINCE" '[.data[] | select(.startedAt > $since)] | length')
          [ "$COUNT" = "0" ] || { echo "$COUNT executions errored since deploy"; exit 1; }

The approval gate is the environment: production line. In the repository settings, under Environments, create production and add yourself or a team as required reviewers; the job then pauses at that point with a button in the Actions UI and nothing reaches prod until someone presses it. GitHub's page on managing environments for deployment covers the reviewer rule and the option to restrict which branches can deploy to it. The secrets can live on the environment too instead of the repository, which means a PR from a fork can't read them.

The changed-files logic uses git diff HEAD~1 HEAD, which is right for squash merges and wrong for a merge commit with several changes behind it. Our repo squashes, so it fits. If yours doesn't, compare against the previous deployed commit stored somewhere, or deploy every file every time, which is 31 PUTs and about 15 seconds for us. Simpler, too.

The smoke test workflow on the n8n side

The canary is a tiny workflow that lives on prod permanently: a Webhook node at /webhook/ci-canary with Header Auth checking X-Canary-Token, a Set node that builds {"ok": true, "version": "..."}, and a Respond to Webhook node that returns it. It proves the instance is up, the reverse proxy routes webhooks, the credential store opens (put the token in a Header Auth credential, not in the node) and publishing worked. It does not prove your invoice workflow is correct; the second step, counting errored executions since the deploy, is a coarse check for that, and it will catch the "workflow has issues" case from the credentials section because a published workflow whose trigger fires into a broken node errors immediately.

A thing I haven't settled: what the API does with the newer nodeGroups and parentFolderId fields when a workflow that uses folders is PUT without them. On our instance the workflows stayed in their folders after a deploy, so the PUT seems to leave those alone, but I haven't read that anywhere and it could change. If your prod instance is organised into folders, deploy one workflow and check where it landed before trusting the loop.

Rollback is git revert of the merge commit followed by the same pipeline, since the file in the repo is the whole workflow. The workflow's previous version is also still on prod for 24 hours in the community edition's history, longer with a paid tier, if you'd rather restore it from the editor while the pipeline runs. If you're setting up Actions for the first time, the GitHub Actions deploy tutorial for a Node.js app on a VPS covers the SSH and secrets side in more detail than there's room for here. The n8n-specific parts are all above.

Automate faster, for less

Bring your winning ideas to life with AMD power, NVMe speed and unmetered bandwidth.