Module 4: Dev, Staging, and Prod Environments in Self-Hosted
6. External secrets and variables
Description
By the end of this lesson you will be able to get configuration values —and secrets in particular— out of the workflow, into the environment it runs in, using the $env expression so a node reads a variable from its environment's .env. You're going to meet n8n's Variables ($vars) and why they're a paid feature, you're going to understand the difference between a configuration variable and a secret, and you're going to know, with plan honesty, what external secrets (Vault, AWS Secrets Manager, GCP, Azure) are as an Enterprise feature and what the equivalent Community alternative is using environment variables.
This matters because it closes the isolation loop. In lesson 5 you got the credential's value out of sight, but it still lived inside the n8n instance. There's a cleaner step: for the workflow not to even contain values that change per environment —a base URL, an account identifier, a secret— but to read them from the environment at runtime. That way, the same order-triage.json runs in dev pointing at the sandbox CRM and in prod pointing at the real CRM, with not a single difference in its JSON, because the environment provides the difference. It's the maximum degree of "one workflow, many environments."
Connection to the module: lesson 4 gave you the per-environment .env; lesson 5, per-environment credentials. This one joins them: it teaches the workflow to consume that .env with $env, closing the pattern. It's also the module's plan-honesty lesson regarding secrets: it separates what's free in Community ($env + .env) from what only Enterprise brings (Variables and external secrets), and gives you the Community workaround for the latter. Lesson 7 raises that honesty one more level, comparing this guide's whole pattern against the paid native environments feature.
A secret doesn't get written into the script: it gets handed over in a sealed envelope
Let's start with the principle organizing the lesson. Imagine a play where a character has to say a real bank account number. There are two ways to handle it.
The bad way: write the real number into the script, in every printed copy. Now the number is in every actor's, technician's, and assistant's copy of the script; anyone holding a copy has the number. And if the number changes tomorrow, every script has to be reprinted.
The good way: the script only has a placeholder written into it —"[ACCOUNT NUMBER]"— and the real number is handed to the actor in a sealed envelope, separately, right before the show. The script can be photocopied and handed out freely, because it doesn't contain the secret. The secret travels through a different, controlled channel, and can be changed without touching the script.
That's exactly the difference between writing a value inside the workflow and reading it from the environment. The workflow is the script: it gets versioned, shared, copied between environments. If you write the real CRM's URL or a secret into it, that value travels with every copy of the script —to the repository, to every environment— and changing it forces you to edit the workflow. If instead the workflow just says $env.CRM_BASE_URL —the placeholder— the environment hands it the real value in an envelope —its .env— different in each environment, changeable without touching the workflow.
The rule that comes out of this, governing the lesson:
Whatever changes per environment or is a secret doesn't get written inside the workflow: it gets read from the environment it runs in. The workflow carries the placeholder; the environment provides the value.
What $env is, with plain analogies
The tool that does this in Community is called $env, and it's an expression. First, two definitions.
An environment variable is a named value that exists around a program, provided by the system that starts it —in our case, by the .env Docker Compose injects into n8n's container. It doesn't live inside the workflow; it lives in the environment surrounding n8n. CRM_BASE_URL, POSTGRES_PASSWORD, and N8N_ENCRYPTION_KEY itself are environment variables.
An expression in n8n is a bit of code you write inside a node's field, between double braces, to compute a value instead of typing it fixed. Instead of typing a URL by hand into an HTTP Request node's "URL" field, you write an expression that produces it. They look like this: {{ ... }}.
$env is the expression that, inside a node, reads an environment variable by its name. If the environment's .env has a line CRM_BASE_URL=https://sandbox.crm.example, then inside a node you can write:
{{ $env.CRM_BASE_URL }}
and n8n, when running, replaces that expression with whatever value the variable has in that environment. In dev it gives the sandbox URL; in prod, where the .env has CRM_BASE_URL=https://crm.cumbre.com, the same expression gives the real URL. The workflow didn't change; the environment provided the value.
A couple of important clarifications, so you don't learn something that confuses you later:
$envgets used in a regular node's fields, written as an expression —for example, in the "URL" field or a header of an HTTP Request node. That's where it makes sense: parameterizing a node's configuration based on the environment.$envisn't available inside the Code node in n8n 2.0. The Code node is restricted and doesn't access environment variables that way. So when you want a value from the environment to enter your workflow, put it in a regular node's field as an expression, don't look for it from code inside a Code node.- On self-hosted, expressions' access to
$envis allowed by default. It's controlled by theN8N_BLOCK_ENV_ACCESS_IN_NODEvariable, whose default value isfalse(meaning access is allowed). If on your instance$envreturns empty, that variable is likely set totrue; it's worth checking your version's docs, since this behavior can change and on n8n Cloud it tends to be more restricted.
Worked example: the CRM URL that changes on its own per environment
Let's see the full pattern with order-triage. order-triage's HTTP Request node queries the CRM. Instead of writing the CRM's URL fixed in the node, we're going to read it from the environment. Remember: you do this on your own instance; the guide doesn't run anything.
Step 1 — Declare the variable in each .env. Add a line to each environment's .env with that environment's CRM base URL. In environments/dev/.env:
CRM_BASE_URL=https://sandbox.crm.example
In environments/prod/.env:
CRM_BASE_URL=https://crm.cumbre.com
Same variable, different value per environment. (If this URL weren't secret —a base URL sometimes isn't— it still gains from living in the .env: it's configuration that changes per environment, which is exactly $env's material. A real secret, like a key, is better handled as an n8n credential, which also encrypts it; $env shines for non-secret or low-risk configuration that varies per environment.)
Step 2 — Declare it in the .env.example too. For the contract to stay complete, add the variable to each environment's .env.example, with the name and no real value (or an obviously example value):
CRM_BASE_URL=
Step 3 — Use $env in the node. In order-triage's HTTP Request node, in the "URL" field, instead of a fixed URL, write an expression combining the environment's base with the endpoint's path:
{{ $env.CRM_BASE_URL }}/customers/{{ $json.customerId }}
Here {{ $env.CRM_BASE_URL }} brings the base according to the environment, and {{ $json.customerId }} brings the current order's customer id (you already know that from building workflows). What to expect: running it in dev, the resulting URL is https://sandbox.crm.example/customers/123; the same node in prod produces https://crm.cumbre.com/customers/123. One workflow, two destinations, zero changes to the JSON.
Step 4 — Restart the environment after changing the .env. A detail that catches a lot of people: environment variables get read when the container starts. If you add CRM_BASE_URL to the .env while n8n is already running, n8n doesn't see it yet. You have to restart the stack for it to pick up the new .env:
docker compose down
docker compose up -d
What to expect: after the restart, {{ $env.CRM_BASE_URL }} already returns the value. If it's still empty, either the .env wasn't saved, or N8N_BLOCK_ENV_ACCESS_IN_NODE is set to true. (The -d in up -d means detached: it brings up the stack in the background and returns your terminal, instead of staying to show the logs.)
Not everything that leaves the workflow is equally sensitive
Before continuing, it's worth refining an idea that prevents confusion: getting a value out of the workflow isn't one single thing. There's a sensitivity spectrum, and where each value should live depends on where it falls on that spectrum. Thinking of it as "secret yes / secret no" is too coarse; there are degrees.
Let's walk the spectrum from less to more sensitive, with an order-triage example of each and where it lives best:
1. Public configuration that changes per environment. The CRM's base URL (https://sandbox.crm.example vs. https://crm.cumbre.com) isn't really a secret —anyone using the system sees it— but it changes per environment. It lives well in the .env and gets read with $env. Putting it there isn't for security; it's for portability between environments.
2. Identifiers that change per environment. Cumbre's account id in an external service, or a project number: not high-value secrets, but they vary per environment and we don't want them written inside the workflow. Also $env + .env.
3. Low-value secrets. A sandbox API key with limited spend, or a test account's token: if it leaked, the damage is limited. It can be handled as an n8n credential (which encrypts it) or, in a pinch, via .env with $env. Prefer the encrypted credential.
4. High-value secrets. The production CRM's real key, the AI provider's key with real spend: if these leak, there's serious damage. These live as n8n credentials —which encrypt them with the environment's key— and, at a company with the Enterprise feature, in an external secrets manager. Never in plain text inside the workflow, and with maximum care even in the .env.
The decision rule coming out of the spectrum:
$env+.envis for configuration that changes per environment and for low-value secrets. High-value secrets live as encrypted n8n credentials (and, at scale, in an external manager). Nothing sensitive gets written fixed into the workflow.
Why does a high-value secret prefer to be an n8n credential over an $env variable? For two reasons. First, the credential gets saved encrypted with the environment's N8N_ENCRYPTION_KEY; an $env variable lives in plain text in the .env and in the container's environment. Second, n8n treats credentials with special care: it doesn't show them in execution logs, it hides them in the interface. An $env variable you use in a field could show up in a debug log. So the hierarchy is clear: for high-value things, an encrypted credential; for per-environment configuration, $env. They're not rivals, they're tools for different degrees of sensitivity.
n8n's Variables ($vars): useful, but paid
n8n has another way to store reusable values, different from $env: Variables. These are name-value pairs you define in n8n's interface, and workflows read with the $vars expression:
{{ $vars.crmBaseUrl }}
They sound similar to $env, and for the purpose of "a reusable value I don't want to repeat in every node" they serve a similar function. The practical difference is one of plan: according to the official docs, Variables ($vars) are a paid plans feature —Enterprise on self-hosted, and Pro/Enterprise on Cloud. They're not available on the Community edition, nor on registered Community (the free one that unlocks some extras by registering your email). Since plans change, it's worth confirming this on your version's docs and pricing page.
So, what does this guide use? $env, because it's what works for free on Community. The mental equivalence is direct:
$vars (n8n Variables) | $env (environment variables) | |
|---|---|---|
| Where they're defined | In n8n's interface | In the environment's .env |
| How they're read | {{ $vars.name }} | {{ $env.NAME }} |
| Plan | Paid (Enterprise / Pro) | Community (free) |
| Different per environment | Yes, per instance | Yes, per each environment's .env |
For what we're doing —one value per environment, free— $env with the .env is the way. If your team ever pays for a plan that brings Variables, migrating is simple: you swap $env.CRM_BASE_URL for $vars.crmBaseUrl and define the variable in each instance's interface. Meanwhile, $env gives you the same thing at zero cost.
When does $vars make sense over $env, beyond it being paid? When whoever manages the values is someone who lives in n8n's interface and doesn't touch the server's files: defining a variable on a screen is more convenient for that person than editing an .env and restarting the container. $vars also avoids the restart —you change the variable in the interface and it takes effect without shutting anything down— which is a real advantage in production. But for a technical team already managing each environment's .env, $env covers the need without that extra convenience. As with almost everything in this module: the paid feature buys ergonomics, not a capability you wouldn't otherwise have. Start with $env; if $vars's convenience ever justifies the plan, the migration is a prefix change.
$env isn't just for secrets: also for per-environment behavior
It's worth showing that $env serves for more than URLs and keys. Sometimes you want a workflow to behave differently depending on the environment, without changing its logic. $env is the clean way to do that too.
A concrete example in order-triage. Suppose that in dev you want the workflow, besides classifying, to leave an extra trace in the logs for debugging —print the full order before processing it— but not in prod, so as not to clutter production logs with customer data. That "do I print the detail or not?" is a behavior that changes per environment. Instead of having two workflows, you put a variable in the .env:
# environments/dev/.env
DEBUG_MODE=true
# environments/prod/.env
DEBUG_MODE=false
And in an IF node in order-triage, the condition reads the variable:
{{ $env.DEBUG_MODE === "true" }}
What to expect: in dev, the condition is true and the workflow takes the branch that prints the detail; in prod, it's false and skips that branch. The same order-triage.json, with different behavior per environment, governed by an .env variable, without duplicating the workflow.
Two precautions. First, a type detail: environment variables are always text, so DEBUG_MODE isn't the boolean true, it's the string "true". That's why the comparison is === "true" (against the string), not === true. It's a classic stumble: comparing against the boolean and it never matches. Second, don't overuse this: behaviors that change per environment should be few and low-risk (an extra log, a different limit). The business logic —the 5000 threshold, the classification decision— is the same across the three environments and goes inside the workflow, not behind an $env branch. $env governs behavior's configuration, it doesn't rewrite the logic.
External secrets: what they are and why they're Enterprise
We arrive at the lesson's most important honesty part. There's a category of tool n8n integrates with that's worth knowing even if you can't use it for free: external secrets.
An external secrets manager is a dedicated service, outside n8n, whose only job is to securely store secrets and hand them to the applications that need them, with access control, auditing, and rotation. The best known are HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager (and n8n also integrates 1Password and Infisical). The idea: instead of every application storing its own secrets, they all live in one central, hardened place, and each application requests them when it needs them.
The n8n feature connecting to those managers —so a workflow uses a secret living in Vault without the secret ever touching n8n's database— is, according to the official docs, an Enterprise feature (self-hosted Enterprise and Enterprise Cloud). It's not in Community. As always, plans change; confirm it in your version's docs.
Why does this feature exist and what does whoever pays for it gain? Three things worth a lot at large companies:
- One place for every environment's secrets. Instead of an
.envper environment scattered across several machines, secrets live centralized, and n8n requests them. Changing a secret happens in one place. - Auditing. The manager logs who accessed which secret and when. At a company with compliance requirements, that log is mandatory.
- Managed rotation. The manager can rotate secrets automatically, with no manual intervention.
The Community workaround: .env outside the repo is your "secrets manager"
Here's the good news, which is the pattern this guide uses: for the goal of secrets living outside the workflow and changing per environment, you don't need the Enterprise feature. Your per-environment .env —outside the repository, on each machine— fulfills the essential function of a secrets manager at this guide's scale:
- Secrets live outside the workflow (in the
.env, not the JSON). ✅ - They're different per environment (an
.envper environment). ✅ - They never go to the repository (
.gitignore). ✅ - The workflow consumes them at runtime with
$env. ✅
What the .env doesn't give you, and the Enterprise feature does, is centralization, automatic auditing, and managed rotation. For a small team with three local environments, that difference rarely justifies the cost: a well-kept .env per environment is enough and correct. For a company with many environments, many people, and compliance requirements, centralization starts paying for its price. Lesson 7 gives you the full matrix for deciding; for now, the honest conclusion:
The pattern "secret in the environment's
.env, outside the repo, read with$env" gives you, at zero cost, the essence of a secrets manager. Vault, AWS, and company (via the Enterprise feature) add centralization, auditing, and rotation —valuable at scale, unnecessary to start.
And if you ever want something in between without paying for n8n's feature, there's the path of an external manager that injects secrets as environment variables when the container starts: the manager delivers the values, Docker puts them in the environment, and n8n reads them with $env just like today. It's more setup work, but it keeps the cost at zero and gets you closer to centralization. You don't need it for this module; keep it on your radar for when you grow.
That pattern's shape, in broad strokes and without going into detail since it's advanced material, would be: instead of writing the secrets by hand into the .env, a startup script asks the external manager for the values —for example, with Vault's CLI— and exports them as environment variables right before bringing up the stack. n8n never sees the manager; it only sees environment variables, which it consumes with $env exactly as in this module. What changed isn't how n8n reads the secret, but where the value came from: instead of a static .env, a central manager that can rotate and audit it. It's the same $env as always, fed by a more robust source. That continuity —the workflow doesn't change even if where the secrets come from changes— is exactly what makes starting with $env + .env a solid foundation and not a dead end: you grow by changing the source, not the workflow.
Common mistakes
Writing the secret or the real URL inside the workflow (conceptual, and it defeats everything). What happens: someone puts the real CRM's URL, or worse a token, directly into a node's field, fixed. That value now travels in the JSON: to the repository, to the three environments, to every copy. In dev you end up pointing at the real CRM, and the secret is written into a versioned file. Why it happens: writing the fixed value is the fastest thing and "works" while testing. How to spot it: search your workflows' JSON for production URLs or strings that look like keys; if they're there, they're a problem. How to fix it: whatever changes per environment or is a secret gets read from the environment with $env (or handled as a credential). The workflow carries the placeholder, not the value.
Changing the .env and expecting n8n to see it without restarting (practical). What happens: someone adds CRM_BASE_URL to the .env while n8n is running, tests {{ $env.CRM_BASE_URL }}, and it returns empty. They conclude $env "doesn't work." Why it happens: environment variables get read when the container starts; a change to the .env doesn't reach an already-running process. How to spot it: $env.NEW_VARIABLE empty right after adding it to the .env without restarting. How to fix it: restart the stack (docker compose down && docker compose up -d) so n8n picks up the updated .env. Rule: you touched the .env, restart the environment.
Believing $vars works on Community (conceptual). What happens: someone reads a tutorial using {{ $vars.something }}, copies it onto their Community instance, and it doesn't work. They waste time thinking they wrote it wrong. Why it happens: $vars and $env look similar, and many tutorials assume a paid plan without saying so. How to spot it: if you use $vars on Community and the variable doesn't resolve, it's the feature, not your syntax. How to fix it: on Community use $env with the .env; save $vars for when you have a plan that includes it. And as a good habit, when a tutorial uses a feature, check what plan it's on before assuming you have it.
Trying $env inside a Code node and getting frustrated (practical). What happens: someone wants a value from the environment inside a Code node in n8n 2.0 and can't get it, because the Code node is restricted and doesn't access $env that way. Why it happens: it's natural to assume that if $env exists, it works anywhere. How to spot it: $env that doesn't resolve specifically inside a Code node, even though it works in other nodes. How to fix it: read the environment value in a regular node's field (an expression in an HTTP Request, for example) and pass it into the Code node through its input data if you really need it there. Environment configuration comes in through node fields, not from inside the Code node.
Exercises
Exercise 1 — Inside or outside the workflow? For each value, say whether it should be written inside the workflow (fixed in the node) or live outside, in the environment (read with $env or as a credential), and why: (a) the CRM's base URL, different in dev and prod; (b) the 5000-peso threshold for sending an order to review; (c) the CRM's authentication token; (d) the name of the customerId field the order carries; (e) Cumbre's account identifier in an external service, different per environment.
See solution
(a) Outside, with $env: it changes per environment. {{ $env.CRM_BASE_URL }}.
(b) Inside: the 5000 threshold is business logic, the same across the three environments. It's part of what the workflow decides, not configuration that varies per environment. (If Cumbre wanted a different threshold per environment for testing, then it would go to the .env; but by default it's logic and goes inside.)
(c) Outside, and moreover as an n8n credential (which encrypts it), not as plaintext $env: it's a high-value secret. $env is for configuration; for a key, the encrypted credential is better.
(d) Inside: the customerId field's name is part of the structure of the data the workflow processes; it doesn't change per environment.
(e) Outside, with $env: it's configuration that changes per environment (the test account in dev, the real one in prod).
Why it works: the criterion separating "inside" from "outside" is "does it change per environment or is it a secret?" If it's logic the same everywhere (b, d), it goes inside; if it varies per environment or is a secret (a, c, e), it goes outside. And among "outside," high-value secrets prefer being encrypted credentials over plaintext $env.
Exercise 2 — Write the expression. Each environment's .env has a CRM_BASE_URL variable. You want order-triage's HTTP Request node to query the environment's CRM's /customers/{id} endpoint, where {id} comes from the order's customerId field. Write the full "URL" field expression, and then say what URL it produces in dev (with CRM_BASE_URL=https://sandbox.crm.example) for an order whose customerId is A-42.
See solution
The "URL" field's expression:
{{ $env.CRM_BASE_URL }}/customers/{{ $json.customerId }}
In dev, with CRM_BASE_URL=https://sandbox.crm.example and an order whose customerId is A-42, it produces:
https://sandbox.crm.example/customers/A-42
The same node, unchanged, in prod (with CRM_BASE_URL=https://crm.cumbre.com) would produce https://crm.cumbre.com/customers/A-42.
Why it works: the expression combines two sources —the environment ($env.CRM_BASE_URL, different per environment) and the order's data ($json.customerId, different per run)— into a single URL. Seeing the same node produce different URLs in dev and prod without being touched is "one workflow, many environments" made concrete.
Exercise 3 — Community or Enterprise. Classify each capability as "free on Community" or "only on a paid plan," and for the paid ones, say what the Community equivalent is: (a) reading a value from the .env with $env; (b) defining Variables in the interface and reading them with $vars; (c) a workflow using a secret stored in HashiCorp Vault via the external secrets integration; (d) having different secrets per environment, outside the repository.
See solution
(a) Free on Community. $env + .env is this guide's base pattern.
(b) Paid (Enterprise self-hosted / Pro-Enterprise Cloud). Community equivalent: $env with the .env, which fulfills the same function of "a reusable value per environment."
(c) Paid (Enterprise). Community equivalent: the .env outside the repo as a small-scale "secrets manager," or —with more setup— an external manager that injects secrets as environment variables when the container starts, which n8n reads with $env.
(d) Free on Community. It's exactly what you set up in lessons 4 and 5: an .env per environment, ignored by Git. It doesn't require any paid feature.
Why it works: if you classified all four correctly, you're clear on the plan boundary regarding secrets —the essential part (a, d) is free; the convenience and scale (b, c) cost money— and you know the Community workaround for each paid feature. That plan honesty is what lets you make informed decisions instead of believing you need to pay to have secure environments.
Summary and next step
In this lesson you got the values that change per environment —and the secrets— out of the workflow, into the environment it runs in. With the image of the secret that doesn't get written into the script but handed over in a sealed envelope, you locked in the rule: the workflow carries the placeholder, the environment provides the value. You met $env, the expression that reads an environment's .env variable inside a node's field (not inside the Code node, and allowed by default on self-hosted per N8N_BLOCK_ENV_ACCESS_IN_NODE), and used it so order-triage's CRM URL changes on its own between dev and prod without touching the JSON. You told $env apart from n8n's Variables ($vars), which are paid, and saw the equivalence for migrating if you ever pay for it. And you faced, with honesty, external secrets (Vault, AWS, Azure, GCP): an Enterprise feature adding centralization, auditing, and rotation, whose essential Community equivalent is your per-environment .env outside the repo —free and enough to start.
Before moving on you should be able to: explain why a value that changes per environment doesn't get written inside the workflow; write an expression with $env; say which plan $vars and external secrets are on, and what the Community equivalent is; and remember you have to restart the environment after changing the .env.
Lesson 7 climbs to the module's last level of plan honesty. You've already seen, feature by feature, what's free and what costs money. Now you're going to look straight at n8n's native environments feature —the environments and version control built into the interface, which are paid— and you're going to get an honest decision matrix: when this guide's zero-cost self-hosted pattern is more than enough, and when team size, budget, or compliance make it worth paying. And you'll see what migrating from self-hosted to Cloud would look like if your team grows.
Resources
- Custom variables — n8n Docs — what n8n's Variables (
$vars) are, how they're defined and read, and which plans they're available on; confirm here since it changes. - Environment variables in nodes — n8n Docs — how
$envaccesses environment variables and theN8N_BLOCK_ENV_ACCESS_IN_NODEvariable that controls it. - External secrets — n8n Docs — what external secrets are, which managers it integrates (Vault, AWS, Azure, GCP, 1Password, Infisical), and which plan they're on (Enterprise).
- Use external secret stores — n8n Docs — how the external secrets integration gets configured, to size up what it adds over the
.env. - Expressions — n8n Docs — the syntax of
{{ ... }}expressions in node fields, the basis for using$envand$json.