Module 4: Dev, Staging, and Prod Environments in Self-Hosted

8. Project: three isolated environments running

Description

By the end of this project you will have, running at the same time on your machine, Cumbre's three environmentsdev, staging, and prod— each with its own encryption key, its own test or real credentials, and its own webhook, and you will have demonstrated isolation: that a change in one environment doesn't touch the others, and that a secret encrypted in one environment doesn't decrypt in another. You're going to produce the module's deliverable —the compose files, each environment's .env.example, and a documented isolation test— which is the concrete evidence that you know how to set up the pattern job postings ask for when they write "experience with staging and production environments."

This matters because it's where the whole module stops being concepts and becomes something running that you can show. A system owner doesn't say "I know about environments"; they turn on three, and demonstrate they're isolated. This project is your proof, for yourself and for an interview, that you crossed the line: you no longer depend on "testing carefully in production," because you have the padded room built and verified with your own hands.

Connection to the module: this project brings everything together. Lesson 3's skeleton (environments/{dev,staging,prod}/ with its common blueprint), lesson 4's .env files with different keys, lesson 5's test vs. real credentials, and lesson 6's per-environment values with $env, all combine here into a working system. Lesson 7 gave you the criterion for knowing this self-hosted pattern is the right choice to start; this lesson makes it real. And it looks ahead: the three environments you leave running are the stage for Module 5 (testing in dev at zero cost) and Module 6 (promoting from dev to staging to prod).

What you're going to build, in one picture

Before the step-by-step sequence, the full picture of what you're going to have at the end, so you know where you're headed. Think of it as the finished building's blueprint before touring the floors.

cumbre-automations/
├── workflows/
│   └── order-triage.json          ← ONE version, shared by the three environments
├── environments/
│   ├── dev/
│   │   ├── docker-compose.yml      ← the blueprint (identical across environments)
│   │   ├── .env.example            ← the contract (gets versioned)
│   │   └── .env                    ← dev's real values (NOT versioned)
│   ├── staging/
│   │   ├── docker-compose.yml
│   │   ├── .env.example
│   │   └── .env                    ← staging's real values (NOT versioned)
│   └── prod/
│       ├── docker-compose.yml
│       ├── .env.example
│       └── .env                    ← prod's real values (NOT versioned)
├── docs/
│   └── isolation-test.md           ← the documented isolation test (deliverable)
└── .gitignore                      ← ignores **/.env, versions **/.env.example

And running, three separate stacks:

EnvironmentCompose projectPortURLEncryption keyCredentials
devcumbre-dev5678http://localhost:5678its own (<dev-key>)sandbox / local
stagingcumbre-staging5679http://localhost:5679its own (<staging-key>)sandbox / low limit
prodcumbre-prod5680http://localhost:5680its own (<prod-key>)real

Three instances, three databases, three sets of volumes, three different keys, three ports. The same order-triage.json in all three. That's what you're going to turn on and verify.

A resource note before starting: running three n8n instances with their databases at once uses memory. If your machine is tight, bring up the environments one at a time to test each step, and only turn on all three at once for the final isolation test. You don't need all three on all the time; you need them on together only the moment you're demonstrating they coexist without touching each other. And remember: you run all these commands on your machine; the guide doesn't run anything.

Phase 1 — Prepare the structure and the .env files

Step 1 — Confirm the skeleton. If you've been following the module, you already have environments/{dev,staging,prod}/ with the docker-compose.yml (common blueprint, from lesson 3) and the .env.example (contract) in each folder. Confirm it:

ls environments/dev environments/staging environments/prod

What to expect: in each folder, at least docker-compose.yml and .env.example. If something's missing, go back to lesson 3 and recreate it; the blueprint is the one from there.

Step 2 — Generate three different encryption keys. One per environment, with openssl:

openssl rand -hex 32    # run it three times; save each result separately

What to expect: three 64-character strings, all different. Write them down associated with their environment (dev/staging/prod) and save them in your password manager: they're each environment's master keys, and if you lose them, you lose access to that environment's credentials.

Step 3 — Create each environment's real .env. In each folder, copy .env.example to .env and fill it in with that environment's values. For dev:

# environments/dev/.env  (NOT versioned)
COMPOSE_PROJECT_NAME=cumbre-dev
N8N_PORT=5678
N8N_HOST=localhost
WEBHOOK_URL=http://localhost:5678/
N8N_ENCRYPTION_KEY=<dev-key>         # the first one you generated
POSTGRES_USER=cumbre
POSTGRES_PASSWORD=<dev-password>       # random, different per environment
POSTGRES_DB=n8n
CRM_BASE_URL=https://sandbox.crm.example

Repeat for staging (project cumbre-staging, port 5679, WEBHOOK_URL with 5679, its own key and password, staging's CRM_BASE_URL) and for prod (project cumbre-prod, port 5680, WEBHOOK_URL with 5680, its own key, and CRM_BASE_URL=https://crm.cumbre.com).

What to expect: three .env files, one per folder, similar in structure but different in the lines that matter: project name, port, WEBHOOK_URL, encryption key, database password, and CRM URL. Those differences are each environment's identity.

Step 4 — Verify the secrets are out of Git's reach. The security reflex, before any commit:

git status

What to expect: you see the docker-compose.yml files and the .env.example files, but none of the three .env files. If an .env shows up, stop and fix .gitignore (**/.env with the !**/.env.example exception) before continuing. This check isn't optional: it's the difference between a professional repo and a key leak.

Phase 2 — Bring up the three environments

Step 5 — Bring up dev. Go into its folder and start it in the background:

docker compose -f environments/dev/docker-compose.yml --env-file environments/dev/.env -p cumbre-dev up -d

Let's break down the command: -f tells it which docker-compose.yml to use; --env-file tells it which .env to read; -p cumbre-dev sets the project name (the "address" that isolates the stack); up -d brings it up in the background. (If you prefer, you can cd environments/dev and just run docker compose up -d, since Compose picks up the folder's .env and uses COMPOSE_PROJECT_NAME; the long command is explicit so you can see what's happening.)

What to expect: Docker creates the cumbre-dev_n8n_storage and cumbre-dev_postgres_storage volumes, the cumbre-dev_default network, and starts the containers. The terminal returns control to you (because of -d). Open http://localhost:5678 and n8n greets you. That screen is the sign dev is alive.

Step 6 — Bring up staging and prod. The same, pointing at each folder and with each one's project name:

docker compose -f environments/staging/docker-compose.yml --env-file environments/staging/.env -p cumbre-staging up -d
docker compose -f environments/prod/docker-compose.yml --env-file environments/prod/.env -p cumbre-prod up -d

What to expect: two more stacks, with their own prefixed volumes (cumbre-staging_*, cumbre-prod_*). staging responds at http://localhost:5679 and prod at http://localhost:5680. If either fails with "port is already allocated," its N8N_PORT collides with another one: check they're the distinct 5678/5679/5680.

Step 7 — Confirm all three are running, isolated. Ask Docker for the list of projects and volumes:

docker compose ls
docker volume ls

What to expect: docker compose ls shows three projects: cumbre-dev, cumbre-staging, cumbre-prod. docker volume ls shows volumes with the three prefixes: cumbre-dev_postgres_storage, cumbre-staging_postgres_storage, cumbre-prod_postgres_storage, etc. Seeing the three separate prefixes is the first visual evidence of isolation: each environment has its own data drawers, with its own name, sharing none.

Phase 3 — Populate each environment with the workflow and its credentials

Step 8 — Create the credentials in each environment, with that environment's value. In dev (http://localhost:5678), create Cumbre CRM key (Header Auth) with the sandbox token, and Cumbre LLM key with a local Ollama model or a low-limit key. In prod (http://localhost:5680), create credentials with the same name and type but the real values. Repeat in staging with test values.

What to expect: each environment has its two credentials, with identical names across environments and different values. Remember lesson 5's discipline: the name is the shared outlet; the value is what changes per environment. And the golden rule: the real thing only in prod.

Step 9 — Import order-triage into all three. Import the same workflows/order-triage.json into each instance. In each one, open the nodes with a credential and confirm they ended up connected to that environment's correct credential.

What to expect: the same workflow running across the three environments, each one resolving Cumbre CRM key to its own value and {{ $env.CRM_BASE_URL }} to its .env's URL. One workflow, three environments, zero differences in the JSON. If a node comes out with a missing credential, it's the mismatched-id case (lesson 5): select it manually.

Phase 4 — The isolation test (the project's heart)

Here's what really proves you did the work right. It isn't enough for all three to run; you have to prove they're isolated. Let's do it with two concrete tests.

Test A — A change in dev doesn't show up in prod. With all three environments running:

  1. In dev (http://localhost:5678), make a visible, no-risk change: create a new workflow called isolation-marker-dev, or rename order-triage to order-triage (edited in dev). Save it.
  2. Go to prod (http://localhost:5680) and reload the workflow list.

What to expect: in prod, isolation-marker-dev does not show up, and order-triage still has its original name, without the "(edited in dev)." The change you made in dev lives only in dev's database; prod never found out. That absence is the proof: the environments don't share a database, so a change in one is invisible to the other. If the change did show up in prod, something's wrong —probably they share a volume or project— and you need to check each .env's COMPOSE_PROJECT_NAME.

Test B — A secret from one environment doesn't decrypt in another. This test demonstrates the encryption key's wall. The conceptual form, and the concrete form:

  • The conceptual form (reason it through): dev's Cumbre CRM key credential is encrypted with <dev-key>. prod's instance uses <prod-key>, a different one. So, if you took dev's credential's encrypted data and put it into prod's database, prod couldn't decrypt it: to it, it would be garbage. Credentials don't cross because the keys don't match.
  • The concrete form (demonstrate it, optional and with care): shut down dev, change N8N_ENCRYPTION_KEY in its .env to prod's (<prod-key>), and bring it back up. Opening the Cumbre CRM key credential in dev, n8n can no longer decrypt it and shows a decryption error, because the credential was encrypted with <dev-key> and now the instance uses a different key. Undo the change (put <dev-key> back in dev's .env and restart) to recover the credential. This demonstration is what locks in, firsthand, why the key gets fixed and doesn't change —and why each environment needs its own.

What to expect from Test B: you see with your own eyes that changing the key breaks decryption, which confirms a secret encrypted with one environment's key is useless under another's. It's the reason credentials get recreated per environment (lesson 5) and not copied.

Step 10 — Document the isolation test. This is the deliverable that closes the project. In docs/isolation-test.md, write a short document recording what you tested. A skeleton:

# Environment isolation test — Cumbre

## Environments brought up
| Environment | Project | Port | URL |
|---|---|---|---|
| dev | cumbre-dev | 5678 | http://localhost:5678 |
| staging | cumbre-staging | 5679 | http://localhost:5679 |
| prod | cumbre-prod | 5680 | http://localhost:5680 |

## Evidence of separate resources
`docker compose ls` shows three projects; `docker volume ls` shows
volumes prefixed cumbre-dev_ / cumbre-staging_ / cumbre-prod_.

## Test A — a change in dev doesn't show up in prod
Created `isolation-marker-dev` in dev. It does NOT show up in prod. The
environments don't share a database.

## Test B — a secret from one environment doesn't decrypt in another
Each environment has its own N8N_ENCRYPTION_KEY. A credential encrypted in
dev doesn't decrypt in prod because the keys differ. (Verified by swapping
dev's key for prod's: the credential stopped decrypting; reverted.)

## Conclusion
The three environments are isolated: separate data, separate keys,
separate credentials. A change in one doesn't affect the others.

What to expect: a one-page document anyone —a teammate, an interviewer— can read to confirm, without turning anything on, that you set up truly isolated environments. That document, together with the compose files and the .env.example files, is the module's deliverable.

Phase 5 — Close out with cleanup

Step 11 — Verify one last time that no secret slipped through. Before committing the deliverable:

git status
git add -n .    # shows what would be added, without adding anything

What to expect: among what would be added you see docker-compose.yml, .env.example, docs/isolation-test.md, and workflows/order-triage.json, but no .env, no key, no credential. (git add -n is a dry run: it shows what it would do without doing it.) If a secret shows up, don't commit: fix .gitignore first. This is the same reflex from Module 3, and it's the last filter before something sensitive enters the history.

Step 12 — Shut down the environments when you're done. To free up memory, shut down each stack (keeping the data in the volumes):

docker compose -p cumbre-dev down
docker compose -p cumbre-staging down
docker compose -p cumbre-prod down

What to expect: the containers and networks get removed, but the volumes —with your workflows, databases, and keys— stay there. Next time you bring up the environments, everything is where you left it. Shutting down doesn't delete: to delete the data you'd need down -v, and that command gets used with full awareness.

If something doesn't start: quick diagnosis

Bringing up three environments at once is where the most things can go wrong, almost always from configuration details, not concepts. Before the underlying mistakes, a quick guide to the most common startup stumbles. Remember: these diagnostic commands are read-only; you run them.

Symptom: "port is already allocated" when bringing up the second or third environment. Two environments are asking for the same port on your machine. Check each .env's N8N_PORT: they should be 5678, 5679, 5680, all different. If a previous environment was left half-shut-down, it might still be holding its port; close it with docker compose -p <project> down and try again.

Symptom: n8n doesn't respond at its URL, even though the command "finished fine." The container might still be starting up (the database takes a few seconds to become healthy, and n8n waits per depends_on). Look at the logs:

docker compose -p cumbre-dev logs -f n8n

logs -f shows the logs live (the -f follows them). What to expect: startup lines and, at the end, a message that the editor is available. If you see repeated database connection errors, check that POSTGRES_USER/POSTGRES_PASSWORD match between the postgres section and the DB_POSTGRESDB_* variables (they come from the same .env, so they should).

Symptom: {{ $env.CRM_BASE_URL }} returns empty. Either the .env doesn't have that line, or you changed the .env without restarting (variables get read on startup), or N8N_BLOCK_ENV_ACCESS_IN_NODE is set to true. Confirm the line in the .env, restart the environment, and verify $env access.

Symptom: a credential shows "can't be decrypted" after restarting. That .env's N8N_ENCRYPTION_KEY changed compared to when the credential was created (lesson 4). If you have the original key, put it back; if you lost it, recreate the credential. Check you didn't copy another environment's .env over it.

See the status of all a given environment's containers:

docker compose -p cumbre-dev ps

What to expect: the n8n and postgres services with running status (and postgres as healthy). If either is restarting or exited, its logs (logs) tell you why. This ps is your first look when something "isn't there" but you don't know what.

Most of these stumbles get fixed in a minute once you know where to look. The difference between getting frustrated and solving it is having the reflex to go to the logs and the ps instead of guessing. That reflex —diagnosing with data, not with hunches— is, again, what makes a system owner.

If your machine is tight: the sequential variant

Running three n8n instances with three databases at once demands memory, and not every machine has plenty to spare. If yours chokes with all three on, don't abandon the project: do it in turns, which demonstrates the same thing with less load.

The idea: most steps —creating credentials, importing the workflow, verifying each environment starts— don't need all three at once. Bring up one, work on it, shut it down, bring up the next. Only isolation Test A (a change in dev doesn't show up in prod) needs two environments on at the same time, and two is enough —you don't need all three. So:

  1. Bring up dev, create its credentials, import order-triage, make the marker change (isolation-marker-dev). Leave it on.
  2. Bring up prod (now there are two). Check that isolation-marker-dev does not show up in prod. That's Test A, with only two environments.
  3. Shut down dev to free up memory. Finish populating prod with its real credentials.
  4. You can set up and verify staging separately, with no need for the other two to be up.

You see the evidence of separate resources (docker volume ls with the three prefixes) even with the containers shut down, because the volumes persist. So you can bring things up and shut them down whenever you want: the three environments' data drawers stay there, with their prefixes, ready to show the isolation. The sequential variant doesn't demonstrate less; it just spreads the load over time. Isolation is a property of how things are set up, not of them all being on at once.

The deliverable checklist

Before calling the project done, verify you have the deliverable's three pieces, which are the ones an interviewer or a teammate would review:

  • The compose files. Each environment's docker-compose.yml (the common blueprint), in environments/{dev,staging,prod}/.
  • The .env.example files. Each environment's configuration contract, with the variable names and no secret values, versioned.
  • The documented isolation test. docs/isolation-test.md with the environments brought up, the evidence of separate resources, and Tests A and B.
  • No secrets. Confirmed with git status: no .env, no key, no credential in what's versioned.

If you checked all four, you have the module's complete deliverable: the evidence that you know how to set up and demonstrate isolated environments, packaged so another person understands it without you being present. That "someone else can, without me" is, again, the standard the whole guide chases.

Common mistakes

Bringing up all three under the same project and believing they're isolated (conceptual, and it defeats the project). What happens: someone forgets to change COMPOSE_PROJECT_NAME or the -p, brings up all three with the same project name, and since Docker treats them as the same stack, they actually have just one that keeps replacing itself. Test A "fails" because the change in dev does show up in "prod" (which is the same instance). How to spot it: docker compose ls shows a single project instead of three. How to fix it: a unique COMPOSE_PROJECT_NAME per environment; that name is what creates the isolation. Without three distinct projects, there are no three environments.

Putting the real credential in dev during the project (practical, and dangerous). What happens: for convenience, someone uses the CRM's real key in dev "just to make the test work." Now dev's tests touch real data, exactly what the whole module is trying to prevent. How to spot it: if a dev credential's value is the real production one, there's a problem. How to fix it: sandbox or local values in dev and staging; real only in prod. If you already used the real one in dev and exposed it, rotate it.

Forgetting to restart after changing an .env and thinking isolation is broken (practical). What happens: someone changes an .env (for example, CRM_BASE_URL) with the environment running, doesn't see the change reflected, and concludes "something crossed between environments." Actually, n8n never reread the .env because it wasn't restarted. How to spot it: a change to the .env that doesn't show up, without having restarted. How to fix it: after touching an .env, docker compose ... down && ... up -d for that environment. Variables get read on startup.

Committing an .env in the rush of the deliverable (practical, and the most serious one). What happens: at the end of the project, eager to "save everything," someone runs git add . and an .env with real keys slips through. How to spot it: git status or git add -n . show an .env among what would be added. How to fix it: never git add . without first confirming with git status that there's no .env in the list; trust the **/.env .gitignore, and if for some reason it fails, don't commit until it's fixed. The deliverable is the compose files, the .env.example files, and isolation-test.md, never an .env.

Exercises

Exercise 1 — Design a third isolation test. Tests A (a change in dev doesn't show up in prod) and B (a secret doesn't cross) demonstrate data and key isolation. Design a third, different test that demonstrates another facet of isolation, and describe what you'd do and what you'd expect to see. Hint: think about ports, or credentials, or shutting down an environment.

See solution

There are several good answers. Three examples:

  • Independent-ports test: shut down only dev (docker compose -p cumbre-dev down) and confirm staging (5679) and prod (5680) keep responding. You'd expect that shutting down one environment doesn't affect the others: each one lives in its own stack, so one going down doesn't drag the others along. It's availability isolation.

  • Independent-credentials test: in dev, delete the Cumbre CRM key credential. Go to prod and confirm its Cumbre CRM key is still intact. You'd expect deleting the credential in one environment doesn't touch the other's, because they live in separate databases.

  • Execution-data test: run order-triage with a test order in dev and check the execution history. In prod, the history doesn't have that execution. You'd expect one environment's executions not to show up in another's.

Why it works: designing your own test forces you to understand what isolation is —separate resources in every form: data, keys, ports, credentials, executions— instead of repeating the two given tests. A system owner doesn't just pass the tests handed to them; they invent the ones that are needed.

Exercise 2 — Trace a value's flow. Follow the CRM URL's full journey in prod, from where it's written to where it's used. Name each place it passes through: (1) where the real value lives, (2) how it reaches n8n's container, (3) how the workflow reads it, (4) what final URL it produces for an order with customerId=Z-9. Use CRM_BASE_URL=https://crm.cumbre.com.

See solution

(1) The real value lives in environments/prod/.env, in the line CRM_BASE_URL=https://crm.cumbre.com. That file is outside the repository (ignored by .gitignore).

(2) When you bring up prod with docker compose ... --env-file environments/prod/.env ..., Docker Compose reads that .env and injects CRM_BASE_URL as an environment variable inside prod's n8n container.

(3) order-triage's HTTP Request node reads the variable with the expression {{ $env.CRM_BASE_URL }} in its "URL" field, combining it with the path: {{ $env.CRM_BASE_URL }}/customers/{{ $json.customerId }}.

(4) For an order with customerId=Z-9, the final URL is https://crm.cumbre.com/customers/Z-9.

Why it works: tracing the value end to end —.env → container → expression → final URL— confirms you understand how an environment's configuration reaches the workflow's execution without being written inside the JSON. It's "one workflow, many environments" seen as a complete flow, not a loose idea.

Exercise 3 — Defend your project in an interview. An interviewer says: "I see you set up three environments with Docker Compose. Convince me they're really isolated and that you know why each piece is where it is." Write your answer in one paragraph, touching at least: the isolation mechanism, the per-environment encryption key, where the secrets live, and how you'd demonstrate it live.

See solution

There's no single answer; a good one would touch these points: "Each environment runs as a different Docker Compose project —cumbre-dev, cumbre-staging, cumbre-prod— and that project name makes Docker create separate volumes, containers, and networks for each one, so their databases don't touch: it's isolation by construction, not fragile configuration. Each environment has its own N8N_ENCRYPTION_KEY, deliberately different, so a credential encrypted in dev can't be decrypted in prod; that isolates secrets between environments. The real secrets —the CRM's and the model's keys— live only in prod's .env, outside the repository, and the workflow consumes them without containing them: the credential's reference and the $env expressions travel in the JSON, the values don't. I'd demonstrate it live by bringing up all three, making a change in dev and showing it doesn't show up in prod, and using docker volume ls to show each environment has its own volumes. All of this runs on Community, at zero cost; the paid feature would only add interface convenience and auditing, which at this scale I don't need."

Why it works: the answer doesn't say "I set up environments"; it explains the mechanism (project name), the security (per-environment key, secrets out of the repo), and the evidence (how it's demonstrated), and places the plan decision. That combination —how it works, why it's secure, how I prove it, what it costs— is exactly what separates, in an interview, a system owner from a workflow builder.

Summary and next step

In this project you turned on the whole module. You brought up Cumbre's three environments —dev, staging, and prod— running at the same time, each as its own Docker Compose project (cumbre-dev/cumbre-staging/cumbre-prod), with its port (5678/5679/5680), its .env with its own encryption key and password, its test or real credentials, and its WEBHOOK_URL. You imported the same order-triage.json into all three, with each environment resolving the credential and $env.CRM_BASE_URL to its own. And —the project's heart— you demonstrated the isolation with two tests: a change in dev not showing up in prod (separate data) and a secret not decrypting under another environment's key (separate keys), documented in docs/isolation-test.md. You closed with the usual security reflex: git status confirms no .env slips through; the deliverable is the compose files, the .env.example files, and the documented test, never an .env.

With this you met the module's exit capability: you bring up isolated environments with their own key and their own credentials, and demonstrate that a change in one doesn't touch the others. That's exactly what job postings ask for when they write "experience with staging and production environments," and now you have it built, verified, and documented.

Module 5 enters those environments you left running and answers the question that follows: you already have an isolated dev where you can get things wrong without fear, but how do you actually test there, at zero cost? You're going to learn to generate synthetic data, use test accounts and sandbox keys, do dry runs that don't trigger real effects, pin data and replay executions, and test AI Agent-node workflows using the Starter Kit's local Ollama models, without spending a cent. The padded room is already built; Module 5 teaches you to use it fully.

Resources