Module 3: Exporting, Normalizing, and Structuring the Repository
3. Separating credentials from workflows
Description
By the end of this lesson you will be able to keep your workflows' credentials completely outside the repository, so that no secret —not even encrypted— ever reaches Git. You'll know what the N8N_ENCRYPTION_KEY is and what role it plays, exactly what the n8n export:credentials command does and why its --decrypted flag is a security risk that has to be handled with gloves, the difference between a credential's reference (which does travel to the repo) and its value (which never does), and how to set up a .gitignore that shields your secrets from the first commit.
This matters more than anything else in this module, and that isn't an exaggeration. An ugly diff is a nuisance; a leaked credential is a security incident. order-triage uses two real credentials —the AI model's key and the CRM's key— and one distracted git add . is enough to publish them. If the repository ends up on GitHub, that key gets exposed to bots scanning for leaked secrets around the clock. This is the lesson that separates someone who delivers a professional system from someone who, without realizing it, hands the world access to their client's CRM.
Connection to the module: in lesson 2 you exported workflows. This one exports —with extreme care— credentials, which are a world apart because they're secret. The rule you learn here shapes everything that follows: the repository structure (lesson 5) reserves a place for credentials' schema but never for their values, and the export script (lesson 7) is going to be designed to never touch a secret. Think of this lesson as the one installing the security reflex the others take for granted.
A method warning before starting. In this lesson you're going to see how the
--decryptedflag is used, because you need to understand what it produces to know why it's dangerous. But seeing it explained isn't the same as running it lightly. When you run it on your own machine, do it fully aware of what comes out of it: secrets in plain text. Treat them the way you'd treat cash sitting on the table.
What a credential is, and why it's different from a workflow
Let's start with the object. A credential in n8n is the secret a node uses to authenticate itself to an external service: an API key, an OAuth token, a username and password, a webhook secret. It's the proof of identity that tells the other service "I am who I say I am, let me in."
order-triage has two:
- The language model key the AI Agent node uses to classify orders. Without it, the node can't call the model.
- The CRM key the HTTP Request node uses to query customer data. Without it, the CRM answers "unauthorized."
Here's the fundamental difference from a workflow, and it's the one governing this whole lesson. A workflow is logic: it describes what happens, in what order, with which nodes. Logic can be looked at, compared, versioned, and shared without risk —in fact, we want to share it, that's the point of the repository. A credential is a secret: its entire value lies in nobody else having it. The moment a credential stops being secret, it stops working; worse, it becomes an open door.
Think of it as the difference between your house's blueprints and your door's key. You can photocopy the blueprints, mail them, hang them on the wall: showing how your house is built doesn't make it insecure. The key is the opposite: its entire value depends on only you having it. Photocopying the key and handing it out is exactly what you shouldn't do. The workflow is the blueprints. The credential is the key. This module versions the blueprints and keeps the key far from the paper.
How n8n stores credentials: encryption at rest and the master key
To understand why "not even encrypted" goes to the repo, first you need to see how n8n stores credentials internally.
When you create a credential in the editor and type your API key, n8n doesn't save it as-is. It encrypts it before writing it to its database. Encrypting means turning readable text into an unreadable jumble using a key, so only whoever has that key can reverse it. This is called encryption at rest: your secrets are encrypted while they "rest" in the database, so even if someone stole the database file, they'd see garbage, not your keys.
And which key does it encrypt with? With the N8N_ENCRYPTION_KEY. This is your instance's master key: a string of characters n8n uses to encrypt and decrypt all credentials. It's worth knowing well, because it's the center of the whole security matter:
- What it is: a secret key, unique to your instance, that all of that instance's credentials get encrypted with.
- Where it comes from: per the official documentation, n8n automatically generates a random key the first time it starts and saves it in the
~/.n8nfolder (in a configuration file, under the nameencryptionKey). You don't have to do anything for it to exist. You can also set your own, putting it in theN8N_ENCRYPTION_KEYenvironment variable before the first start; then n8n uses that instead of generating one. - Why it matters so much: the encrypted credential and the master key are the two halves of the same lock. The encrypted credential without the key is useless garbage. The key without the encrypted credential opens nothing. But the two together decrypt all your secrets. Whoever has both has your API keys in plain text.
The analogy: N8N_ENCRYPTION_KEY is a safe's combination, and the encrypted credentials are what's inside the safe. If someone steals the safe but doesn't have the combination, they can't open it. But if someone gets the combination and the safe, they take everything. That's why the combination and the safe never travel together, and that's why —you're about to see— not even the closed safe goes to the repository.
A practical detail Module 4 develops further and is worth flagging now: each of Cumbre's environments —dev, staging, prod— has its own N8N_ENCRYPTION_KEY. That means a credential encrypted in dev can't be decrypted in prod, because the master keys are different. It's a desired property: it isolates secrets between environments. For now hold on to the idea; you'll actually use it in Module 4.
Exporting credentials: n8n export:credentials and the dangerous flag
The CLI has export:workflow's twin for credentials: n8n export:credentials. It shares almost every flag you already know —--all, --id, --output, --separate, --pretty, --backup— and adds one that doesn't exist for workflows and that has to be handled with extreme care: --decrypted.
| Flag | What it does |
|---|---|
--all | Exports all the instance's credentials. |
--id=<id> | Exports a single credential by its identifier. |
--output=<path>, -o | Where to write the file or the folder. |
--separate | One file per credential. Requires a folder in --output. |
--pretty | Readable formatting. |
--backup | Shortcut for --all --pretty --separate. |
--decrypted | Exports the credentials in plain text. Only for credentials. |
The official description of --decrypted is brutally honest, and it's worth quoting it as-is: "Exports the credentials in a plain text format. All sensitive information is visible in the files." In other words, it exports the credentials in plain text; all sensitive information becomes visible in the files. That is, your API keys, as-is, readable by anyone who opens the file.
So there are two ways of exporting credentials, and the difference is enormous:
Without --decrypted (default): the file contains the credentials still encrypted. Each secret's value comes out as a block of unintelligible characters, encrypted with your instance's N8N_ENCRYPTION_KEY. It looks like this (trimmed, with the encrypted block shortened):
[
{
"id": "5",
"name": "Cumbre CRM key",
"type": "httpHeaderAuth",
"data": "U2FsdGVkX1+8f3a...unreadable encrypted block...9c4d=="
}
]
That data is the jumble. Without the correct master key, it can't be turned back into readable text. This format is the one used to migrate from one instance to another that shares the same key, or as an encrypted backup kept in a safe place.
With --decrypted: the same file, but with data in plain text:
[
{
"id": "5",
"name": "Cumbre CRM key",
"type": "httpHeaderAuth",
"data": {
"name": "Authorization",
"value": "Bearer sk-crm-live-9f2c8a1b4e7d6003"
}
}
]
There's Cumbre's CRM key, Bearer sk-crm-live-9f2c8a1b4e7d6003, in plain sight for anyone. This format exists for a legitimate reason: migrating credentials to another instance that has a different master key. Since the other instance can't decrypt what got encrypted with your key, the only way to bring credentials over is to export them in the clear, move them through a secure channel, and import them there (where they get re-encrypted with the destination's key). It's a migration tool, not an everyday backup one, and everything it produces is material that never, under any circumstance, touches a repository.
The golden rule: not even the encrypted version goes to the repo
Here we arrive at the lesson's heart. It's tempting to reason like this: "the export without --decrypted comes out encrypted, and encrypted is safe, so I can commit that one." No. Not even the encrypted one. There are three reasons, and it's worth understanding all three because the reasoning transfers to any secret, not just n8n's.
Reason 1: half the lock in a permanent place. The encrypted credential is useless alone, true. But it's half the lock. The other half —the N8N_ENCRYPTION_KEY— lives on your server, in environment variables, in deployment scripts, in backups... in a bunch of places where it could leak on its own. The day that key leaks by any route, everything the attacker needs to decrypt your secrets is the other half. And if that other half is in a Git repository, they already have it served to them. Committing the encrypted credential is leaving half the attacker's work ready, forever.
Reason 2: Git's history is forever. This is what makes this mistake so dangerous. When you commit a file and later realize it and delete it in a later commit, the file stays in the history. Git keeps everything: the version where the secret existed remains recorded, and anyone who clones the repository can recover it with a couple of commands. Deleting the file today doesn't delete that it was there. The only real way to remove something from Git's history is rewriting the entire history, a delicate procedure, and if the repo is already on GitHub and someone cloned it, it's already too late. With secrets, the rule is: it's better to never put it in than to try to take it out later.
Reason 3: as documentation, it doesn't serve. Someone might defend the encrypted credential saying "I'm keeping it in the repo as a backup." But it's a bad backup: it's tied to that specific master key, so if you lose the key, the backup is worthless; and if you have the key, the backup is a danger. For the purpose of "documenting what credentials the workflow needs," which is legitimate, you don't need the encrypted value: it's enough to document the credential's name and type, which aren't secrets. We'll cover that in a moment.
The conclusion is one sentence, and I want it to stick with you: credentials, in any form —decrypted or encrypted— live outside the repository. The repository versions the logic and the documentation. Secrets go somewhere else: a password manager, a secrets manager, an offline encrypted backup. Module 4 goes deeper into the "somewhere else"; this lesson makes sure that somewhere else isn't, by accident, your repo.
What does travel: the reference, not the value
If credentials don't go to the repo, how does another developer know which credentials order-triage needs to work? Here comes a fine and very useful distinction: the difference between a credential's reference and its value.
When you open order-triage's JSON that you exported in lesson 2 and look for the HTTP Request node, you're going to find something like this:
{
"name": "Query CRM",
"type": "n8n-nodes-base.httpRequest",
"credentials": {
"httpHeaderAuth": {
"id": "5",
"name": "Cumbre CRM key"
}
}
}
Look closely at what's there and what isn't. There's a pointer: "this node uses a credential of type httpHeaderAuth, which on my instance has id 5 and is called Cumbre CRM key." There's no secret at all. The CRM key doesn't show up anywhere, only its name and its type. It's like a label saying "the front door key goes here" without being the key.
That reference does travel to the repository, and it's perfectly fine for it to, because it isn't secret. In fact it's useful: it tells whoever reads the workflow which credential is needed and of what type. When that developer imports the workflow into their instance, they're going to have to create a credential of type httpHeaderAuth with their own CRM key and connect it; the reference tells them exactly what to create.
So, what does the repository version regarding credentials? Three things, none secret:
- The reference, which already comes included inside the workflow's JSON (you do nothing extra: it travels on its own).
- A document listing which credentials each workflow needs, with its type and purpose —"
Cumbre CRM key, Header Auth type, gives read access to the CRM." This is part of lesson 6's handoff documentation. - A
.env.examplefile with the secret variables' names but without their values, just placeholders. Module 4 develops this; for now hold on to the idea that the example carries the shape, not the content.
The real value —the key— isn't in any of the three. It's outside, in the safe place. The repository says what's needed; the real secret gets provided separately, on each instance.
The .gitignore that shields secrets from the first commit
All this discipline rests on a concrete safety net: .gitignore. It's the tool that makes it so that even if you get distracted with a git add ., the secrets don't get in anyway.
A .gitignore is a text file, living at the repository's root, where you write —one per line— the paths and file patterns you want Git to completely ignore. An ignored file doesn't show up in git status, can't be added with git add by accident, and Git acts as if it doesn't exist. It's a "don't look here" list.
Think of it as the "don't touch" list you leave for someone house-sitting: "everything in this drawer, don't even open it." .gitignore is that closed drawer for Git. What's inside doesn't get pushed, period.
How it works internally: every line is a pattern. A loose name (.env) ignores that file. A folder with a slash (credentials/) ignores everything inside that folder. An asterisk (*.credentials.json) is a wildcard: the * means "anything," so that pattern ignores any file ending in .credentials.json. Lines starting with # are comments, and Git ignores those too.
This is a sensible .gitignore for an n8n repository like cumbre-automations:
# --- Secrets: never to the repository ---
# Environment variables with real values (keys, tokens, passwords)
.env
.env.*
!.env.example
# Any credential export, encrypted or decrypted
credentials/
*.credentials.json
credentials-backup.json
*-decrypted.json
# n8n's data folder, which contains the encryption key and the database
.n8n/
# --- System and tooling noise ---
node_modules/
.DS_Store
Let's go line by line over the ones that matter:
.envand.env.*— ignores the environment variables file with real values, and any variant like.env.prodor.env.local. That's where the real secret lives on each machine.!.env.example— the exclamation mark is an exception: it means "this one, don't ignore it." Since the previous line ignored every.env.*, this one specifically rescues.env.example, the example file without real values, which we do want to version as a template. It's the only.envvariant that travels.credentials/,*.credentials.json,credentials-backup.json,*-decrypted.json— any credential export, regardless of how you named it or whether it's encrypted or in the clear. It's a deliberately wide net: you'd rather over-ignore than let a secret through..n8n/— n8n's data folder, which contains nothing less than theN8N_ENCRYPTION_KEYand the database with the encrypted credentials. If this folder snuck into the repo, you'd upload the master key and the safe at once. Ignoring it is mandatory.
Worked example: the correct order, and why order is everything
The detail that makes or breaks this protection is order. The .gitignore has to exist before you make the first git add that could catch a secret. Let's see it as a sequence, on cumbre-automations.
Step 1 — Create the .gitignore first, before anything else. As soon as you initialize the repository, even before exporting a single credential, write the .gitignore above at the root. This is the first file that exists, not the last.
Step 2 — Now export the credentials, to a place the .gitignore already covers. If you want an encrypted backup (not for the repo, for a safe place), export like this:
docker exec -u node -it n8n n8n export:credentials --all --output=credentials-backup.json
Notice: without --decrypted, so it comes out encrypted. And the name credentials-backup.json is already on the ignored list, so even if it lands inside the repo's folder, Git isn't going to see it. Move that file to your safe backup and delete it from your working directory.
Step 3 — Verify Git doesn't see it. Before committing anything, run:
git status
What to expect: in the list of files Git sees, credentials-backup.json does not show up. Neither does .env nor the .n8n/ folder if they existed. If the .gitignore is set up right, those files are invisible to Git. If they do show up, stop: the .gitignore isn't right, and you're one git add away from an accident. Check the patterns before continuing.
Step 4 — Only now, commit. With the certainty that the secrets are invisible, add the files that do go in —the normalized workflows, the README, the .env.example— and commit calmly.
Order matters because .gitignore doesn't erase the past: it only prevents the future. If you did git add . before creating the .gitignore, and at that moment there was a credentials-backup.json in the folder, Git would have already captured it, and adding the .gitignore afterward wouldn't take it out of the history. That's why .gitignore is the repository's first citizen, not a late addition.
If the accident already happened: rotate, don't just delete
Let's suppose the worst: you already committed a credential, even already pushed it to GitHub. What do you do? The correct answer surprises a lot of people:
The first thing is not deleting the file from Git. The first thing is rotating the credential.
Rotating a credential means invalidating the old one and generating a new one in the service that issued it: you go into the CRM's panel, revoke the key sk-crm-live-9f2c8a1b..., and create a new one. The instant you revoke it, the leaked one stops working: even if a bot already copied it from the repo, it opens nothing. Afterward, calmly, you update the credential in n8n with the new key, and then you clean up Git's history.
Why in that order? Because once a secret has been public, even for a minute, you have to assume someone copied it. Deleting it from Git closes the door, but if someone already went in, closing the door doesn't take them out. Rotating the credential changes the entire lock: it no longer matters who has the old key. Cleaning up Git is necessary, but it's the second step, not the first. Security isn't about hiding the leaked secret; it's about making it stop being valid.
Common mistakes
Committing the encrypted credential believing encrypted means safe (conceptual, and serious). What happens: someone exports credentials without --decrypted, sees the unreadable block, concludes "this is encrypted, I'll push it, no problem," and commits it. Why it happens: "encrypted" triggers the "safe" intuition, which is half correct: encryption protects if and only if the master key never leaks, and the key lives in many places where it could leak. How to spot it: if at any point you reason "it's encrypted, so I can push it," that phrase is the warning sign. How to fix it: apply the rule with no exceptions —credentials in any form, outside the repo— and trust the .gitignore so they don't get in even by accident. The logic goes to the repo; the secret, never.
Putting the .gitignore after the first git add . (practical). What happens: someone initializes the repo, runs git add . to "add everything," commits, and then remembers the .gitignore. But that add . swept in a .env or a credential export, which already stayed in the history. Adding the .gitignore now doesn't take it out. Why it happens: git add . is convenient and captures everything present, including secrets you haven't ignored yet. How to spot it: review your first commit with git show --stat HEAD and look for any .env, credentials, .n8n, or file with "secret"/"key" in the name. How to fix it: .gitignore comes first, always, before the first add. And if a secret already snuck in, first rotate the credential, then clean up the history. Preventing is infinitely cheaper than repairing.
Confusing a credential's reference with the secret (conceptual). What happens: someone sees in the workflow's JSON the block "credentials": { "httpHeaderAuth": { "id": "5", "name": "Cumbre CRM key" } } and panics thinking the key is right there, or the opposite, deletes that block believing it protects something. Why it happens: the word "credentials" shows up, and it's easy to assume the secret is right there. How to spot it: look at whether the block contains a value that looks like a key (sk-..., Bearer ..., a password) or just an id and a name. If it's the latter, it's a reference, not a secret. How to fix it: leave the reference alone —it's useful and isn't secret; the real secret lives in the instance's credential, not in the workflow's JSON. Confusing the two leads to either uploading secrets out of misdirected fear or breaking the workflow by deleting its pointer.
Using --decrypted for everyday backup (practical and dangerous). What happens: someone reads that --decrypted "exports the credentials" and adopts it as their usual backup command, generating files with plain-text secrets all over their working folder. Why it happens: the word "exports" sounds like "backs up," but --decrypted is a migration tool between instances with different keys, not a backup one. How to spot it: if you have .json files with readable API keys on your disk, --decrypted is running loose. How to fix it: to back up, export without --decrypted (it comes out encrypted) and keep it in a safe place outside the repo; reserve --decrypted for the specific moment of migrating credentials to another instance, moving them through a secure channel and deleting them as soon as you're done.
Exercises
Exercise 1 — Classify what travels and what doesn't. For each of these six elements, say whether it should go into the repository (cumbre-automations) or not, and why in one sentence: (a) the order-triage workflow's JSON with its credentials reference block; (b) the credentials-backup.json file exported without --decrypted; (c) the .env file with the CRM's real key; (d) the .env.example file with CRM_API_KEY= and nothing after it; (e) the .n8n/ folder; (f) a docs/order-triage.md document saying "requires a Header Auth credential for the CRM."
See solution
(a) Yes, it goes in. It's the workflow's logic. The credentials block containing only id and name is a reference, not a secret.
(b) No, it doesn't go in. It's a credential export; even encrypted, it's half a lock and Git's history is forever. Keep it outside the repo, in a safe place. The .gitignore already covers it.
(c) Never goes in. It contains the CRM's real key in plain text. It's the pure secret. The .gitignore ignores it with .env.
(d) Yes, it goes in. It's the template: it has the variable's name but not its value. It documents what's needed without leaking anything. It's the exception rescued by !.env.example in the .gitignore.
(e) No, it doesn't go in. It contains the N8N_ENCRYPTION_KEY and the database with the encrypted credentials —the master key and the safe together. Ignoring it is mandatory.
(f) Yes, it goes in. It's documentation: it says what credential is needed and of what type, without revealing the value. It's exactly what another developer needs to rebuild the system.
Why it works: if you got all six right, you've already internalized the dividing line governing the repo's security. Logic and documentation (a, d, f) travel. Secrets and their containers (b, c, e) stay out. The one that confuses almost everyone is (b): "it's encrypted, why not?" Because encrypted isn't the same as forever safe.
Exercise 2 — Write the .gitignore and verify. In a test repository, create this lesson's .gitignore. Then, create three empty files by hand to simulate the danger: .env, credentials-backup.json, and .env.example. Run git status and note which ones show up and which don't. Explain the result.
See solution
git status should show only .env.example (and .gitignore itself). The other two, .env and credentials-backup.json, don't show up: they're ignored.
The reason: .env falls under the .env rule; credentials-backup.json falls under credentials-backup.json (and also under *.credentials.json if you had named it that way). But .env.example, even though the .env.* rule would ignore it, gets rescued by the !.env.example exception, so Git does see it and you can version it as a template.
Why it works: seeing with your own eyes that git status doesn't show the secrets —even while they're right there, in the folder— is what turns .gitignore from an abstract concept into a safety net you trust. And discovering that .env.example does show up teaches you the ! exception mechanism, which is the detail most people get wrong.
Exercise 3 — The incident. A coworker writes to you, distressed: "I accidentally pushed a .env with the client's CRM API key to GitHub. I already deleted the file and committed again. Are we good?" Answer in three or four sentences: what's wrong with their solution, what they should do first, and in what order.
See solution
They aren't good yet, and the problem is that deleting the file isn't enough. The key was public, so you have to assume someone already copied it —the bots scanning GitHub for secrets are automatic and fast. Also, deleting the file in a new commit doesn't take it out of the history: it's still recoverable in the previous commit.
The first thing they should do is rotate the credential: go into the CRM's panel, revoke that key, generate a new one, and update it in n8n. The moment they revoke it, the leaked key stops working, no matter who copied it. Only afterward do they clean up Git's history (or, depending on the case, consider the repo compromised). The order is: rotate first, clean up second. Security isn't about hiding the leaked secret, it's about it stopping being valid.
Why it works: this exercise corrects the most dangerous intuition that exists around leaked secrets —"I deleted it, we're fine." Understanding that the only real fix is rotation prepares you to respond correctly the day it happens to you or someone on your team, which, given enough time, happens to almost everyone.
Summary and next step
In this lesson you saw why credentials are a world apart from workflows: a workflow is logic, which we want to share; a credential is a secret, whose entire value depends on nobody else having it. You understood how n8n stores them encrypted at rest with the N8N_ENCRYPTION_KEY —the master key it generates only on the first start and saves in ~/.n8n— and that the encrypted credential and the master key are the two halves of the same lock. You met n8n export:credentials and its --decrypted flag, which exports secrets in plain text ("all sensitive information is visible in the files") and exists only for migrating between instances with different keys. You memorized the golden rule: not even the encrypted version goes to the repository, because of the half-the-lock problem, because Git's history is forever, and because as documentation it doesn't work. You told apart a credential's reference (a pointer with id and name, which does travel) from its value (the secret, which never does). And you set up the .gitignore that shields everything —before the first commit, not after— plus the correct reflex in the event of an accident: rotate the credential first, clean up Git second.
Before moving on you should be able to: explain why an encrypted credential doesn't go to the repo either; tell apart, in a JSON, a credential's reference from its value; write from memory the .gitignore lines protecting .env and credential exports; and say what the first step is when facing a leaked secret.
Lesson 4 goes back to the world of workflows, now without security weighing on it. Now that you export clean workflows and keep secrets out, there's an aesthetic problem that's actually an engineering one: the exported JSON carries fields that change on their own —pinData, version identifiers, timestamps— and clutter every diff. You're going to write a small normalization script, with jq or Node, that removes that noise and orders the keys stably, turning an unreadable diff into a change that can actually be reviewed.
Resources
- Use the command line — n8n Docs — the official reference for
export:credentialsand its--decryptedflag, with the literal warning that all sensitive information becomes visible. - Set a custom encryption key — n8n Docs — what
N8N_ENCRYPTION_KEYis, where n8n saves it, and how to set your own. - Rotate encryption keys — n8n Docs — how to rotate the instance's master key, the instance-level equivalent of rotating a leaked credential.
- Credentials — n8n Docs — how credentials get created and managed in n8n, and how a node references them by type.
- gitignore — Git Documentation — the official reference for the
.gitignoreformat: patterns, wildcards, and the!exception.