Module 4: Dev, Staging, and Prod Environments in Self-Hosted
4. Environment variables and encryption keys
Description
By the end of this lesson you will be able to give each Cumbre environment its own configuration through an .env file: you're going to fully understand what an .env file is and how Docker Compose reads it, you're going to generate a different N8N_ENCRYPTION_KEY per environment and explain precisely what breaks if you share it or change it, and you're going to configure each environment's own WEBHOOK_URL and host so their URLs don't cross. You're also going to close out the module's security reflex: .env with real values always outside the repository, .env.example inside as the configuration contract.
This matters because the .env is where everything that sets one environment apart from another lives. In lesson 3 you left a common blueprint full of ${...} gaps; this lesson fills them in, and every badly filled gap is a real problem: a shared key is a security crack, a wrong WEBHOOK_URL means broken webhooks, a committed .env is an incident. And there's a deeper reason: N8N_ENCRYPTION_KEY is the most delicate piece in the whole system. Understanding it well —what it encrypts, what happens if it's lost, why each environment has its own— is what separates someone who operates real environments from someone who copies commands and prays.
Connection to the module: lesson 3 set up the skeleton —three folders, a common blueprint, .gitignore. This one furnishes it with each environment's .env. It picks up directly from Module 3, lesson 3, where you met N8N_ENCRYPTION_KEY and the rule of "secrets out of the repo"; here that rule becomes operational per environment. Lesson 5 is going to build on what you learn here about the encryption key to explain why credentials get recreated in each environment instead of copied. Think of this lesson as the one that turns three identical skeletons into three environments with their own identity.
What an .env file is, with plain analogies
Let's start with the lesson's central object. An .env file —read "dot env," from environment— is a plain text file where you write, one per line, variables in the format NAME=value. Nothing more: names to the left of the =, values to the right, one pair per line.
POSTGRES_USER=cumbre
POSTGRES_PASSWORD=k7Rx9mQ2vL8pN4wT
N8N_PORT=5678
Its purpose is to separate the configuration from the code. The docker-compose.yml —the blueprint— shouldn't change between environments; what changes are the values. The .env is where those values live. When Docker Compose reads the blueprint and finds a gap like ${N8N_PORT}, it goes to that folder's .env, looks for a line that starts with N8N_PORT=, and fills the gap with whatever's to the right of the =.
Think of it as a fill-in-the-blanks form. The blueprint is the printed form: "Name: ____, Port: ____, Password: ____." The same form works for many people. The .env is the form already filled in by a specific person: dev's puts in some values, prod's puts in others. Same form, different answers. That's why one blueprint works for three environments: each brings its own .env with its own answers.
Three properties of the .env worth being clear on right away:
- Docker Compose reads it automatically if it's named exactly
.envand it's in the same folder you're runningdocker composefrom. You don't have to say "use this file"; if it's there, it picks it up. (You can also pass it manually with--env-file, but the automatic way is the convenient one.) - It's plain text, no quotes or semicolons.
N8N_PORT=5678, notN8N_PORT="5678";. Lines starting with#are comments and get ignored. - It contains real secrets. The real
.envcarries the encryption key and the database passwords unencrypted, in plain sight. That's why —and this is the rule that doesn't break— the.envnever gets uploaded to the repository. You're going to see it in detail at the end of the lesson, but lock it in from now: the.envis a private file per machine, not part of the repo.
N8N_ENCRYPTION_KEY: review and deeper dive
You already met the encryption key in Module 3. Let's review it in one sentence and then dive into what this module adds.
The N8N_ENCRYPTION_KEY is an n8n instance's master key: the string of characters n8n uses to encrypt every credential before saving it to its database, and to decrypt it when a workflow needs it. It's one key per instance, and it encrypts all of that instance's secrets. Remember the Module 3 analogy: the key is a safe's combination, and the encrypted credentials are what's inside. The combination and the safe together open everything; separately, each one is useless.
According to the official docs, there are two ways an instance can get its key:
- Automatic: if you don't give it one, n8n generates a random key the first time it starts up and saves it in the
~/.n8nfolder (in its config file). You don't have to do anything; it appears on its own. In our Docker stack, that~/.n8nfolder lives inside then8n_storagevolume, so the generated key persists there. - Explicit: you pass it your own key through the
N8N_ENCRYPTION_KEYenvironment variable before the first start, and n8n uses that one instead of generating one. That's exactly what ourdocker-compose.ymldoes:N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}takes the key from the.env.
Why do we prefer the explicit one, if the automatic one "works on its own"? For two reasons that are this module's heart:
First: control. If you let each environment generate its key on its own, you don't know what it is, it lives hidden inside a volume, and if that volume ever gets lost or recreated, the key goes with it and you have no copy. With an explicit key in the .env, you know it, back it up in your secure place, and can recreate the environment without losing access to the encrypted credentials.
Second: you make it different per environment on purpose. This is the module's new point. If you let each environment generate a random key on its own, your three environments are going to have different keys —good— but by accident, and without you controlling them. By setting them yourself in each .env, you make them different deliberately, you know what they are, and you treat them like the secrets they are.
Why each environment has its own key, and why sharing it is dangerous
This is the idea this module adds on top of Module 3, and it's worth pausing on. Each Cumbre environment has its own N8N_ENCRYPTION_KEY, different from the other two. It isn't an oversight: it's a security decision. Let's look at the two consequences, one desired and one to avoid.
The desired consequence: secrets don't cross between environments. Since dev and prod encrypt with different keys, a credential encrypted in dev is unreadable garbage in prod, and vice versa. Even if someone copied dev's entire database to prod, dev's credentials couldn't be used in prod, because prod's key doesn't decrypt them. This is a wall: it isolates one environment's secrets from the others'. If dev's key —the least protected one, the one most people touch— ever leaked, it's useless to an attacker for reading prod's secrets, because prod encrypts with a different key. Different keys = watertight compartments.
The consequence to avoid: sharing the key joins the compartments. If you used the same N8N_ENCRYPTION_KEY across all three environments, you'd break that wall. A credential encrypted in dev could be decrypted in prod, and —worse— the day the key leaks through the weakest environment (dev, where you experiment, where you test things, where care is lowest), that same key opens prod's secrets. Sharing the key makes your production's security depend on your playground environment's security. It's exactly backward from what you want. Hence: one key per environment, deliberately different, never shared.
What happens when the key changes (the mistake that terrifies juniors)
There's a scenario that causes panic and is worth understanding before it happens to you. If an instance's N8N_ENCRYPTION_KEY changes after it already encrypted credentials, those credentials stop being decryptable.
The mechanism is direct: the credentials are encrypted with the old key. If you start the instance with a new key, n8n tries to decrypt them with the new one, it doesn't match, and it fails. In the interface you're going to see errors like "the credential couldn't be decrypted," and the workflows depending on those credentials stop authenticating. The database is intact, the workflows are intact, but the secrets are locked with a key you no longer have.
This happens more than you'd think, from specific slip-ups:
- You bring up the stack with the automatic key (n8n generated it and saved it in the volume), and later you set a different explicit key in the
.env. The new one doesn't match the one that encrypted the credentials. - You delete the
n8n_storagevolume (where the automatic key lived) and recreate the instance: the new key doesn't decrypt the old data. - You copy one environment's
.envto another and, with it, its key, over an instance that already had credentials encrypted with a different one.
The practical lesson is twofold. One: fix the explicit key in the .env from each environment's first start, and don't change it. Two: back up every key in your secure place (a password manager), because if you lose it, you lose access to all of that environment's credentials. The key isn't a configuration detail; it's the only copy of your safe's combination. (n8n has an official procedure to rotate the key in a controlled way, which re-encrypts the credentials with the new key; that's different from changing it abruptly, which is what breaks everything.)
Worked example: generating and placing a key per environment
Let's generate three keys —one per environment— and place them in their .env files. Remember: you run these commands; the guide doesn't run them.
Step 1 — Generate a random key. An encryption key isn't a word you make up; it's a long, random string, impossible to guess. The standard way to generate it is with openssl, a cryptography tool you almost certainly already have installed:
openssl rand -hex 32
Let's break down the command: openssl rand generates random bytes; -hex shows them as hexadecimal characters (0-9 and a-f), easy to copy unambiguously; 32 is the number of bytes, which in hex translates into a 64-character string. What to expect: a line like this (yours will be different, it's random):
9f2c8a1b4e7d60039f2c8a1b4e7d60039f2c8a1b4e7d60039f2c8a1b4e7d6003
Step 2 — Run it three times, one per environment. Each environment needs its own key, so you run the command three times and get three different strings. What to expect: three 64-character strings, all different. That difference is what isolates secrets between environments.
Step 3 — Place each key in its environment's .env. Remember that in lesson 3 you created the .env.example files. Now, in each environment folder, you create the real .env from the example and fill it in. For dev:
# environments/dev/.env (this file does NOT get uploaded to the repo)
COMPOSE_PROJECT_NAME=cumbre-dev
N8N_PORT=5678
N8N_HOST=localhost
WEBHOOK_URL=http://localhost:5678/
N8N_ENCRYPTION_KEY=9f2c8a1b4e7d6003... # the FIRST string you generated
POSTGRES_USER=cumbre
POSTGRES_PASSWORD=k7Rx9mQ2vL8pN4wT # another random string, different per environment
POSTGRES_DB=n8n
And for prod, the same form with different answers:
# environments/prod/.env (this file does NOT get uploaded to the repo)
COMPOSE_PROJECT_NAME=cumbre-prod
N8N_PORT=5680
N8N_HOST=localhost
WEBHOOK_URL=http://localhost:5680/
N8N_ENCRYPTION_KEY=3d5e0a2f9c4b7108... # the THIRD string: DIFFERENT from dev's
POSTGRES_USER=cumbre
POSTGRES_PASSWORD=Zq2Wp9Lm4Xn7Vb1 # another password, different from dev's
POSTGRES_DB=n8n
Look at the two differences that matter between dev and prod: N8N_ENCRYPTION_KEY is another string (watertight compartments), and so is POSTGRES_PASSWORD (separate databases, with separate access credentials). POSTGRES_USER and POSTGRES_DB can match without a problem, because each environment has its own isolated database; what can't match is whatever grants access or decrypts secrets.
Step 4 — Verify Compose reads the .env. When you enter the environment's folder and bring up the stack, Compose picks up that folder's .env automatically. You can confirm the variables resolved correctly, without starting anything, with:
docker compose config
config asks Compose to show the blueprint already filled in by the .env, without bringing up containers. What to expect: the docker-compose.yml printed out with ${N8N_PORT} replaced by 5678, etc. If you still see the gaps as ${...} or empty, the .env isn't being read (check that it's named exactly .env and is in that folder). It's a safe way to check the configuration before turning on the engine.
Each environment's own URLs: N8N_HOST and WEBHOOK_URL
The encryption key isn't the only thing that changes per environment. URLs do too, and here there's a detail that trips up a lot of people the first time they separate environments.
order-triage starts with a Webhook node. A webhook is a URL n8n creates so an external service can "knock on the door" and trigger the workflow: when an order arrives, Cumbre's system makes a request to that URL, and n8n starts order-triage. n8n builds the webhook URL from its host and protocol configuration, and that's why it's different in each environment.
Two variables govern this:
N8N_HOST— the hostname n8n believes it's running on. Locally it'slocalhost. On a real server it would be the domain (n8n.cumbre.com), but remember here we bring up local environments, solocalhostfor all three. Its default value islocalhost.WEBHOOK_URL— the base address n8n uses to build the webhooks' URLs. This is the critical one. If you don't set it right, n8n builds the webhook URLs with the host and port it assumes, and in an environment running on a non-standard port, that assumption fails.
The concrete problem: dev runs on 5678, staging on 5679, prod on 5680. If in staging you left WEBHOOK_URL pointing at 5678, n8n would show external services a webhook URL pointing at dev, not staging. An order aimed at staging would knock on dev's door, or the request would fail because the URL doesn't match. The webhook URL and the environment's real port have to tell the same story.
That's why in each .env, WEBHOOK_URL travels with the port:
| Environment | N8N_PORT | WEBHOOK_URL |
|---|---|---|
dev | 5678 | http://localhost:5678/ |
staging | 5679 | http://localhost:5679/ |
prod | 5680 | http://localhost:5680/ |
The mechanical rule: every time you change an environment's port, change its WEBHOOK_URL too. They travel as a pair. It's one of the easiest things to forget and one of the ones that causes the most confusion, because the symptom —"my webhook points at the wrong place"— doesn't scream "it's WEBHOOK_URL"; you have to know where to look. Now you do.
Other .env secrets and a per-environment checklist
N8N_ENCRYPTION_KEY is the .env's star secret, but not the only one. It's worth knowing the others so you don't accidentally leave one with an example value.
POSTGRES_PASSWORD— that environment's database password. Since each environment has its own isolated database, each one carries its own password, different and random. Even though the database isn't exposed to the internet (remember: it has no public port), a random password is still the right practice; don't leavepassword.N8N_USER_MANAGEMENT_JWT_SECRET— if your stack uses it (it appears in the official Starter Kit), it's the string n8n uses to sign the sessions of users logging into the editor. Like the encryption key, it's a random secret worth setting yourself, different per environment, not left at the example value. Confirm its exact detail in your version's docs.
The rule that unifies all of them: any .env value that's a password, a key, or a secret gets generated at random, made different per environment, and never left at the example value. What can safely repeat between environments is non-secret configuration: POSTGRES_USER, POSTGRES_DB, N8N_HOST. Whatever grants access or encrypts, never.
To avoid loose ends, here's a checklist you can mentally run over each environment's .env before bringing it up:
-
COMPOSE_PROJECT_NAMEis unique to this environment (cumbre-dev/cumbre-staging/cumbre-prod). -
N8N_PORTis this environment's own (5678/5679/5680) and doesn't collide with another. -
WEBHOOK_URLpoints at the same port asN8N_PORT. -
N8N_ENCRYPTION_KEYis a random string, different from the other environments', and backed up in your password manager. -
POSTGRES_PASSWORDis random and different per environment. - No secret value was left with the example placeholder (
password,super-secret-key, empty where a secret should go). - This
.envdoes not show up ingit status(covered by.gitignore).
Running this list over all three .env files takes a minute and prevents the module's three most expensive mistakes: a colliding port, a shared key, and an unchanged secret. A minute of checklist against an afternoon of debugging: it's a good trade.
The module's security reflex: .env out, .env.example in
We arrive at the point that, like in Module 3, matters more than any other in the lesson. And it isn't an exaggeration to repeat it, because it's the mistake that sinks a junior.
The .env with real values never, ever, under any circumstance, gets uploaded to the repository. It contains the encryption key and the database passwords in plain text. If that file reaches Git —and worse, GitHub— you've published your environments' master keys. Bots that scan public repositories for secrets find them in minutes.
What does go to the repository is the .env.example: the same form, with every variable's name but without the secret values. It's the configuration contract: it tells anyone cloning the repo "these are the variables you need to fill in to bring up this environment," without leaking a single key. Whoever receives the repo copies .env.example to .env, fills in their own values, and starts up. The mold travels; the content doesn't.
The distinction, again, is the same one from Module 3:
| File | What it contains | Goes to the repo? |
|---|---|---|
.env | The real values: encryption key, passwords | Never |
.env.example | The variable names, no values | Yes, it's the contract |
And the safety net that makes this mistake-proof is the .gitignore you already put in place in lesson 3:
# Any .env in any folder, including those in environments/*/
**/.env
**/.env.*
!**/.env.example
The **/.env ignores the .env in all three environment folders; the !**/.env.example rescues the templates. That way, even if you run a distracted git add ., the real .env files are invisible to Git.
The reflex, in three steps, you should run before every commit:
- Run
git status. - Confirm the
docker-compose.ymland the.env.examplefiles show up, and that no.envshows up. - If you see an
.envin the list, stop:.gitignoreisn't set right, and you're onegit addaway from publishing a key. Fix it before continuing.
What to expect when running git status with everything set right: you see environments/dev/docker-compose.yml, environments/dev/.env.example, and their staging and prod equivalents, but none of the three .env files. The secrets exist on your disk, but they're invisible to Git. That invisibility is the sign you did your job right.
Common mistakes
Sharing N8N_ENCRYPTION_KEY between environments (conceptual, and serious). What happens: someone generates a single key and puts the same one in all three .env files, thinking "this is simpler." It breaks the wall between environments: a dev secret decrypts in prod, and the day the key leaks through dev —the more exposed environment— it also opens prod's secrets. Why it happens: having one key to remember is convenient, and the danger is invisible while nothing leaks. How to spot it: compare the N8N_ENCRYPTION_KEY= line across the three .env files; if they're the same, you have the problem. How to fix it: a different key per environment, generated with openssl rand -hex 32, backed up separately. The convenience of one key isn't worth putting your production's security in your test environment's hands.
Changing the key on an instance that already encrypted credentials (practical, and causes panic). What happens: someone starts an environment, creates credentials, and afterward changes N8N_ENCRYPTION_KEY in the .env (or deletes the volume where the automatic key lived). On restart, the credentials "can't be decrypted" and workflows fail to authenticate. Why it happens: it isn't understood that credentials are tied to the key they were encrypted with, and changing the key locks them up. How to spot it: "credential couldn't be decrypted" errors after a key change or a volume deletion. How to fix it: fix the explicit key from the first start and don't change it; back it up. If you really need to change it, use the official rotation procedure, which re-encrypts the credentials, not an abrupt change.
Forgetting to sync WEBHOOK_URL with the port (practical). What happens: someone copies dev's .env to staging, changes N8N_PORT to 5679, but leaves WEBHOOK_URL=http://localhost:5678/. staging's webhooks show URLs pointing at dev, and requests land in the wrong environment or fail. Why it happens: they're two separate variables that actually travel as a pair, and it's easy to change one and forget the other. How to spot it: if the webhook URL n8n shows doesn't match the port you use to reach the editor, they're out of sync. How to fix it: every time you touch N8N_PORT, touch WEBHOOK_URL too, so they point at the same port. They travel together, always.
Losing the key from not backing it up (practical). What happens: someone lets n8n generate the automatic key, doesn't write it down anywhere, and one day recreates the volume or migrates to a new machine. The key went with the volume, and now no credential in that environment can be decrypted. Why it happens: the automatic key is invisible, "works on its own," and so it's easy to forget it exists and that it's irreplaceable. How to spot it: ask yourself "do I know what prod's encryption key is, and do I have a copy of it somewhere safe?" If the answer is no, you're one accident away from losing your credentials. How to fix it: use an explicit key in the .env, and back up every key in your password manager. The key is the only copy of your safe's combination: without it, what's encrypted is lost.
Exercises
Exercise 1 — Diagnose the broken credentials. A teammate writes: "I brought up prod, created the CRM credential, everything worked. Today I restarted and n8n says the credential can't be decrypted. I didn't change the workflow." You ask what they touched, and they admit that yesterday they edited the .env to 'clean it up' and, while at it, replaced N8N_ENCRYPTION_KEY with a nicer new one. What exactly happened, and can the credential be recovered?
See solution
What happened: the CRM credential was encrypted with the original key. By replacing N8N_ENCRYPTION_KEY with a new one, n8n now tries to decrypt with the new key, which doesn't match the one that encrypted the credential, and it fails. The database and the workflow are intact; the secret is locked with a key that's no longer in the .env.
Can it be recovered? Only if your teammate still has the original key somewhere (a backup, the editor's history, the terminal). If they wrote it down or can recover it, they put it back in the .env, restart, and the credential decrypts again. If the original key is completely lost, the encrypted credential is unrecoverable: it has to be recreated —go into the CRM, get the key, and create the credential again in n8n with the new key already fixed in place.
Why it works: this case teaches you the key's most important rule —it doesn't get changed over already-encrypted credentials— by living through the panic secondhand. And it leaves you the lesson of backing up the key: if your teammate had it saved, recovery would have been trivial.
Exercise 2 — Fill in the three .env files (the lines that change). Write, for the three environments, only the five lines that change between them: COMPOSE_PROJECT_NAME, N8N_PORT, WEBHOOK_URL, N8N_ENCRYPTION_KEY, and POSTGRES_PASSWORD. You don't need to actually generate the keys and passwords; write a placeholder that makes clear each one is different (for example <dev-key>, <staging-key>, <prod-key>). Then point out which of those five lines are secrets that never go to the repo.
See solution
dev:
COMPOSE_PROJECT_NAME=cumbre-dev
N8N_PORT=5678
WEBHOOK_URL=http://localhost:5678/
N8N_ENCRYPTION_KEY=<dev-key>
POSTGRES_PASSWORD=<dev-password>
staging:
COMPOSE_PROJECT_NAME=cumbre-staging
N8N_PORT=5679
WEBHOOK_URL=http://localhost:5679/
N8N_ENCRYPTION_KEY=<staging-key>
POSTGRES_PASSWORD=<staging-password>
prod:
COMPOSE_PROJECT_NAME=cumbre-prod
N8N_PORT=5680
WEBHOOK_URL=http://localhost:5680/
N8N_ENCRYPTION_KEY=<prod-key>
POSTGRES_PASSWORD=<prod-password>
The secrets that never go to the repo are the last two lines of each environment: N8N_ENCRYPTION_KEY and POSTGRES_PASSWORD. The other three (COMPOSE_PROJECT_NAME, N8N_PORT, WEBHOOK_URL) aren't secret —they're configuration— and in fact they show up with their values in the .env.example. The secrets appear in the .env.example only with the name and an empty =.
Why it works: separating "the five that change" from "the ones that are also secret" is exactly the criterion that decides what goes in .env.example (everything, but with the secrets empty) and what only goes in the real .env (the secret values). If you're clear on it, you're not going to leak a key by accident.
Exercise 3 — Verify the shielding. In your cumbre-automations skeleton, with the .gitignore and .env.example files in place, create three empty .env files by hand (one in each environment folder) to simulate the danger. Run git status and note what shows up and what doesn't. Explain why the .env.example files show up and the .env files don't, referring to the .gitignore lines.
See solution
git status should show the three docker-compose.yml files and the three .env.example files, but none of the three .env files.
The reason is in the .gitignore lines: **/.env ignores any file named exactly .env in any subfolder, so the three .env files in the environment folders stay out. The !**/.env.example exception specifically rescues the example files, so Git does see those three and you can version them as the contract. The ** is what makes the pattern reach all three environments/*/ folders, not just the root.
Why it works: seeing with your own eyes that git status hides the .env files —even though they're right there, on disk— is what turns .gitignore from an abstract idea into a safety net you trust. And confirming the ** reaches the subfolders saves you from the classic mistake of a .gitignore that only protects the root and leaves environments/'s .env files exposed.
Summary and next step
In this lesson you furnished the three skeletons with their .env. You understood that an .env file is a "fill in the blanks" form —NAME=value, one line per variable— that Docker Compose reads to fill in the blueprint's ${...} gaps, and that's why one blueprint works for three environments with different answers. You dove deep into N8N_ENCRYPTION_KEY: what it encrypts, how n8n generates it (automatically in ~/.n8n, or explicitly via a variable), why we set it explicitly to control and back it up, and why each environment has its own, deliberately different one —so secrets don't cross and so a dev leak doesn't compromise prod. You locked in the terrifying scenario: if the key changes over already-encrypted credentials, those credentials stop decrypting, so it gets set from the first start, never changed, and backed up. You generated keys with openssl rand -hex 32, configured each environment's own N8N_HOST and WEBHOOK_URL —which travel with the port— and closed out the module's security reflex: .env out of the repo, .env.example inside as the contract, with the **/.env .gitignore as the net.
Before moving on you should be able to: explain what Docker Compose does with an .env; say why the encryption key is different per environment and what happens if it's shared or changed; generate a key with openssl; and explain why WEBHOOK_URL changes together with the port.
Lesson 5 picks up the encryption key's thread and carries it to credentials per environment. Now that each environment encrypts with its own key, you're going to understand why a credential doesn't get copied from one environment to another but gets recreated, how the same workflow can reference the "same" credential even though its value changes between environments, and why Cumbre CRM's real keys only live in prod while dev and staging use test accounts. It's where environment separation becomes concrete in the day-to-day work with order-triage.
Resources
- Set a custom encryption key — n8n Docs — what
N8N_ENCRYPTION_KEYis, how n8n generates it in~/.n8n, and how to set your own via environment variable. - Rotate encryption keys — n8n Docs — the official procedure to rotate the key without losing the credentials, different from changing it abruptly.
- Configure webhook URLs — n8n Docs — how n8n builds webhook URLs from the host and port, and why
WEBHOOK_URLgets set; confirm the variable names here for your version. - Environment variables in Compose — Docker Docs — how Compose reads the
.envfile and resolves${...}variables; includesdocker compose configfor verification. - gitignore — Git Documentation — the format reference, including the
**wildcard for subfolders and the!exception.