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

3. One Docker Compose per environment

Description

By the end of this lesson you will be able to define dev, staging, and prod as three isolated stacks with Docker Compose, understanding exactly what mechanism keeps them separate (Compose's project name), which resources —containers, volumes, networks, database, ports— each one has entirely to itself, and what never gets shared between environments and why. You're going to learn the naming and port conventions that keep you from confusing one environment with another, and you're going to leave, inside cumbre-automations, the environments/{dev,staging,prod}/ skeleton set up, which the rest of the module is going to fill in.

This matters because it's the module's technical heart: isolation. Everything we promised in lesson 1 —a padded room for making mistakes, prod untouchable while you test— depends on the three environments actually being separate, not "separate in name." Half-way isolation is worse than none, because it gives you a false sense of security: you think you're testing in dev and you're actually touching prod's data. This lesson teaches you to build real isolation and to verify it is real.

Connection to the module: lesson 2 gave you the base —a reproducible stack— and the pieces (container, volume, network, port). This one takes that single stack and multiplies it by three, isolated. Lesson 4 is going to give each environment its .env with its encryption key and its URLs; 5 and 6, its credentials and secrets. Here you build the skeleton; the following lessons furnish it. It's the same pattern as in Module 3: structure first, then content. In fact, the environments/ folder you set up here is the one you already previewed in Module 3's lesson 5, when you designed the layout "that grows toward environments."

Three buildings with the same blueprint

Let's start with the image we're going to use throughout the module to think about isolation.

Imagine a construction company putting up three apartment buildings with the same architectural blueprint. All three have the same layout: two bedrooms, one bathroom, the kitchen in the same spot. The blueprint is identical. But each building is at a different address, has its own electrical wiring, its own pipes, its own water meter. What happens in the building on Street A doesn't affect the one on Street B at all: if a pipe clogs in A, the water still runs fine in B. They share the blueprint; they don't share the installations.

That's exactly what we're going to build. The three environments —dev, staging, prod— are born from the same blueprint: the same docker-compose.yml, the same service definitions. That's why they're the same by construction, and why order-triage behaves the same in all three. But each one has its own installations: its own database, its own volumes, its own network, its own entry port. A test order that comes into dev gets processed against dev's database, with dev's data, and doesn't brush up against prod even by accident.

The technical question is: how does Docker make sure three stacks born from the same file don't step on each other? The answer has a name, and it's the lesson's central idea.

The isolation mechanism: the project name

When you bring up a stack with docker compose up, Docker Compose doesn't create the containers, volumes, and networks with loose names. It groups them under a project name, and it prefixes everything it creates with that name. By default, the project name is the name of the folder where the docker-compose.yml is.

This sounds like an administrative detail, but it's the mechanism of isolation. Look at it with an example. If you bring up the same stack under two different project names —cumbre-dev and cumbre-prod— Docker creates two completely separate sets of resources:

ResourceUnder the cumbre-dev projectUnder the cumbre-prod project
Database volumecumbre-dev_postgres_storagecumbre-prod_postgres_storage
n8n volumecumbre-dev_n8n_storagecumbre-prod_n8n_storage
n8n containercumbre-dev-n8n-1cumbre-prod-n8n-1
Private networkcumbre-dev_defaultcumbre-prod_default

Notice what just happened: they're the same services, with the same blueprint, but each resource carries its project's prefix, so they're different objects for Docker. cumbre-dev_postgres_storage and cumbre-prod_postgres_storage are two completely separate disk drawers. Data you write to dev's database goes to the first one; it never touches the second. Isolation isn't a promise or a delicate configuration: it's a direct consequence of each environment living under its own project name.

Back to the buildings: the project name is the building's address. "Street A number 10" and "Street B number 20" are the same blueprint at two addresses, and that's why their installations don't cross. Changing the project name is changing address, and with the address, all the installations change too.

There are two ways to set the project name, and it's worth knowing both:

  • By folder: if you put dev's docker-compose.yml in a folder called dev/, Compose uses dev as the project name by default. Simple, but fragile: two dev/ folders in different projects would collide.
  • Explicit, with COMPOSE_PROJECT_NAME: you set that variable in the environment's .env, and Compose uses that name no matter what the folder is called. This is the way we're going to use, because it's explicit and doesn't depend on where the file is. COMPOSE_PROJECT_NAME=cumbre-dev in dev's .env makes it crystal clear, and in writing, under which project that environment runs.

What NEVER gets shared between environments

This is the list you have to lock in, because every element that gets shared by mistake is a crack in the isolation. Between dev, staging, and prod, these are never shared:

The database. Each environment has its own PostgreSQL instance, in its own volume. This is the most important one: the database is where the workflows, the encrypted credentials, the execution data live. If two environments shared a database, a change in one would show up in the other, and isolation wouldn't exist. Separate databases, no exceptions.

The volumes. You already saw it: volumes carry the project's prefix, so they're different per environment. dev's n8n_storage volume stores dev's encryption key; prod's, prod's. Sharing a volume would mean sharing those secrets.

The encryption key (N8N_ENCRYPTION_KEY). Each environment has its own, deliberately different (lesson 4). A credential encrypted in dev can't be decrypted in prod because the keys are different. That impossibility is a security wall, not an inconvenience.

The credentials. Cumbre CRM's real keys only live in prod. dev and staging use sandbox keys. Sharing credentials between environments would mean a test in dev could touch the real CRM. Lesson 5.

The ports. Two containers on the same machine can't listen on the same window. Each environment has its own port: 5678 for dev, 5679 for staging, 5680 for prod. You discovered this yourself in exercise 2 of the previous lesson.

And what does travel from one environment to another? One thing, and in a controlled way: the workflow's logic. order-triage gets built and tested in dev, and once it's ready it gets promoted to staging and then to prod. But that isn't "sharing live": it's a deliberate, versioned handoff, one step at a time. That's Module 6's topic. In this module, the environments are isolated; controlled promotion comes later. For now, the rule is simple: nothing flows between environments on its own. The only thing that moves, moves by hand and on purpose.

Naming and port conventions

With three similar environments running at once, the biggest everyday risk isn't technical: it's human. It's confusing one environment with another and running in prod something you thought you were running in dev. The conventions exist to make that confusion hard.

Project names with a clear prefix. All of them start with cumbre- and end with the environment: cumbre-dev, cumbre-staging, cumbre-prod. The prefix groups them (you know they're Cumbre's) and the suffix distinguishes them (you know which is which). Never use generic names like plain dev: the day you have two projects, one project's dev collides with the other's.

Ordered, memorable ports. One window per environment, in order:

EnvironmentProject namePort on your machineEditor URL
devcumbre-dev5678http://localhost:5678
stagingcumbre-staging5679http://localhost:5679
prodcumbre-prod5680http://localhost:5680

The container's internal port stays 5678 in all three —n8n always listens there inside— what changes is your machine's window pointing at each one. 5678, 5679, 5680: consecutive, easy to remember, impossible to confuse once you internalize them.

A discipline note worth gold: when you work with prod, have your own head raise an alarm. Many teams even show the environment's name visible in n8n's interface (with a banner or a distinct color) precisely so nobody forgets where they're standing. In Community you can achieve something similar with the instance's name. The principle: make it hard to confuse prod with everything else, because the "I thought I was in dev" mistake is the most expensive and the most human.

Worked example: setting up the three skeletons

Let's build the three environments' structure inside cumbre-automations. Remember: you run these commands; the guide doesn't run them. This example's goal is to leave the skeleton ready; the .env's concrete values (encryption key, passwords) belong to lesson 4, so here we only leave the example files.

Step 1 — Create the environments/ folder with one subfolder per environment.

mkdir -p environments/dev environments/staging environments/prod

mkdir -p creates every missing folder in the path. What to expect: inside cumbre-automations, an environments/ folder with three subfolders: dev, staging, prod. Check with ls environments/.

Step 2 — Write the environment's docker-compose.yml. This is the common "blueprint." It's parameterized with ${...} variables that each environment's own .env is going to fill in. For the module we use a lighter version of the stack —just n8n and its database— which is the essential part for isolation. Save it as environments/dev/docker-compose.yml:

# environments/dev/docker-compose.yml
# Blueprint for a Cumbre environment. Identical across environments:
# what changes lives in each one's .env.

volumes:
  n8n_storage:          # THIS environment's ~/.n8n folder (encryption key, config)
  postgres_storage:     # THIS environment's database

services:

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
    volumes:
      - postgres_storage:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
      interval: 5s
      retries: 10

  n8n:
    image: n8nio/n8n:latest
    ports:
      - ${N8N_PORT}:5678          # THIS environment's window (5678/5679/5680)
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_USER=${POSTGRES_USER}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}   # THIS environment's key
      - N8N_HOST=${N8N_HOST}
      - N8N_PORT=5678
      - WEBHOOK_URL=${WEBHOOK_URL}                 # THIS environment's webhook URL
    volumes:
      - n8n_storage:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy

Notice the detail doing all the work: the file doesn't have a single concrete value. Everything that distinguishes one environment from another —the port, the encryption key, the database password, the URL— comes in through a ${...} variable. The blueprint is the same; the .env injects the differences. That's why this exact same file works, as is, for all three environments.

Step 3 — Copy the same blueprint to the other two environments.

cp environments/dev/docker-compose.yml environments/staging/docker-compose.yml
cp environments/dev/docker-compose.yml environments/prod/docker-compose.yml

What to expect: each environment folder now has an identical copy of the docker-compose.yml. And this is correct and intended: the blueprint is the same. (If having three identical copies bothers you, you're right that there are techniques to avoid duplication —a single shared file with --env-file— but to learn the pattern, three explicit copies read better and break less. You can consolidate later.)

Step 4 — Create each environment's .env.example, with no real values. This file is the configuration contract: it says which variables need to be filled in, without saying with what. Save it as environments/dev/.env.example:

# environments/dev/.env.example
# Copy this file to .env in the SAME folder and fill in THIS environment's values.
# The .env with real values does NOT get uploaded to the repo (see .gitignore).

COMPOSE_PROJECT_NAME=cumbre-dev       # The environment's "address"; different per environment

N8N_PORT=5678                         # 5678 dev, 5679 staging, 5680 prod
N8N_HOST=localhost
WEBHOOK_URL=http://localhost:5678/

N8N_ENCRYPTION_KEY=                    # Generate it yourself (lesson 4); DIFFERENT per environment
POSTGRES_USER=cumbre
POSTGRES_PASSWORD=                     # Generate it yourself; different per environment
POSTGRES_DB=n8n

What to expect: an .env.example per environment, with the variable names and comments, but with the secrets (N8N_ENCRYPTION_KEY, POSTGRES_PASSWORD) empty. This file does get versioned; the real .env you'll create from it, doesn't. Repeat the step for staging and prod, adjusting COMPOSE_PROJECT_NAME, N8N_PORT, WEBHOOK_URL to each one's values.

Step 5 — Make sure .gitignore covers every .env. An .env can show up in any of the three environment folders, so the pattern has to reach all of them. In cumbre-automations's root .gitignore:

# Any .env in any folder, including those in environments/*/
**/.env
**/.env.*
!**/.env.example

The ** means "in any subfolder, at any depth." That way, environments/prod/.env —the one with the real production key— gets ignored just like an .env in the root. The !**/.env.example exception rescues the templates, which do go to the repo. What to expect: running git status, you see the docker-compose.yml files and the .env.example files, but no .env. If you see an .env, stop: the pattern isn't right and you're one commit from uploading a key.

With this, the skeleton is set up: three folders, each with its blueprint and its contract, and .gitignore shielding the secrets. What's missing —each .env's real values, and bringing all three up at once— belongs to lesson 4 and the final project.

The networks are separate too (and why it matters)

It's worth pausing a moment on a resource Compose creates without you asking, which reinforces isolation: the network.

When you bring up a stack, Compose creates a private network for it —remember from lesson 2 that inside that network, services find each other by name. And like everything else, that network carries the project's prefix: cumbre-dev_default, cumbre-prod_default. They're different networks. The consequence matters: dev's n8n container and prod's aren't on the same network, so they couldn't talk to each other even if they wanted to. dev's postgres is only reachable from inside dev's network; prod's n8n has no way to see it.

This closes a possible crack. In lesson 2 you saw n8n finds its database just by writing postgres, with no IP. You might wonder: if both environments have a service called postgres, wouldn't they get confused? No, because each postgres lives on its own project's network, and the name postgres only resolves inside that network. dev's n8n looking for postgres finds dev's; prod's finds prod's. Same name, different networks, zero confusion. Network isolation is what makes the same blueprint —with the same service names— work across three environments without them crossing.

Worked example: verifying isolation with commands

It's one thing to trust that environments are isolated and another to see it. Docker gives you commands to inspect the resources and confirm the separation with your own eyes. Remember: you run these commands; the guide doesn't run them. Assume you already brought up cumbre-dev and cumbre-prod.

See the running projects:

docker compose ls

What to expect: a list with the active projects. If cumbre-dev and cumbre-prod show up as two separate entries, you have two real environments. If you expected two and see one, the project names collided (the mistake further down).

See the volumes, with their prefixes:

docker volume ls

What to expect: volumes with both prefixes —cumbre-dev_n8n_storage, cumbre-dev_postgres_storage, cumbre-prod_n8n_storage, cumbre-prod_postgres_storage. Seeing all four, with different prefixes, is the visual proof that each environment has its own data drawers. There's no volume shared between the two.

See the networks:

docker network ls

What to expect: among the networks, cumbre-dev_default and cumbre-prod_default, separate. Each environment, its own network.

These three commands are your isolation "X-ray." When in the final project you have to demonstrate that the environments are separate, this is the evidence: three projects, volumes with three prefixes, three networks. Isolation isn't an act of faith; it shows up in these commands' output.

Why the same blueprint, and not one blueprint per environment

It's worth pausing on a design decision many people struggle with, because it goes against intuition: the docker-compose.yml is the same across the three environments. The temptation is to make a lighter "dev" compose, a more robust "prod" one, each different. Resisting that temptation is the right call, and here's why.

If dev and prod run different blueprints, they stop being comparable. The whole point of having staging is for it to be as close as possible to prod, so that if something works in staging, it works in prod. If their blueprints differ, that guarantee falls apart: a change could work on one blueprint and break on the other, and you'd discover the problem exactly where you didn't want to, in prod. The environments' golden rule is parity: that they resemble each other as much as possible, and that the only differences are the ones you declared on purpose.

There's a legitimate case where an environment does differ from the common blueprint, and it's worth naming so the rule doesn't sound absolute: in dev you sometimes want extra services that don't belong in prod —for example, the Ollama container for testing local AI, or a debugging tool. That's fine, as long as the difference is additive and conscious: dev has the same as prod plus something for development, not a different version of the same thing. The clean way to handle it, when it comes up, is with Compose profiles (like the profiles: ["cpu"] you saw in the kit) or with an override file, so the base blueprint stays common and the extra lives separately. What breaks parity isn't adding a development tool to dev; it's dev and prod running the same service configured differently. The first is healthy; the second is the trap.

That's why the blueprint is common and the differences live, entirely and only, in the .env. When someone asks "how does dev differ from prod?", the answer isn't "you have to compare two different compose files"; it's "open the two .env files and compare." All the differences are in one place, explicit, in a short list of variables. That's what makes the system understandable and auditable. One blueprint, many environments; the differences, always in the configuration.

Common mistakes

Sharing a volume or a database between environments (conceptual, and breaks everything). What happens: someone, to save resources, makes dev and staging use the same database volume, or the same PostgreSQL service. Instantly, a workflow being tested in dev modifies data that staging also sees: the isolation is gone, even though the n8n containers are different. Why it happens: running three databases seems wasteful, and sharing "just the database" sounds harmless. How to spot it: if in two different environments' docker-compose.yml the volume has the same name and they run under the same project, or if they point at the same external database service, they're sharing. How to fix it: each environment, its own project (its own COMPOSE_PROJECT_NAME), and therefore its own volumes and its own database. Data isolation isn't negotiable; it's the whole reason environments exist.

Forgetting to change the port and colliding (practical). What happens: someone copies dev's .env to staging and forgets to change N8N_PORT. Bringing up staging, it fails with "port is already allocated" because dev already has 5678. Why it happens: the whole .env gets copied and it's easy for one value to go unadjusted. How to spot it: the port-in-use error when bringing up the second environment is the unmistakable sign. How to fix it: assign ports by convention (5678/5679/5680) and review each environment's .env before bringing it up. It's one of the easiest mistakes to fix: change one number and done.

Using the same COMPOSE_PROJECT_NAME for two environments (conceptual). What happens: someone leaves COMPOSE_PROJECT_NAME=cumbre in all three .env files. Since the project name is what isolates, now the three environments share a prefix and Docker treats them as the same project: the second up doesn't create a new environment, it replaces the first one. Why it happens: the name seems cosmetic, so it gets copied without changing it. How to spot it: run docker compose ls; if you expected three projects and see one, the names collided. How to fix it: a unique COMPOSE_PROJECT_NAME per environment —cumbre-dev, cumbre-staging, cumbre-prod. The project name isn't cosmetic: it's the building's address, and two buildings can't have the same one.

Making a different docker-compose.yml per environment "to optimize" (conceptual). What happens: someone builds a minimalist compose for dev and a robust one for prod, with different services or configurations. Now staging stops predicting prod, because they don't run the same blueprint. Why it happens: the idea of "optimizing each environment" sounds responsible. How to spot it: if comparing dev with prod requires reading two different compose files instead of two .env files, you broke parity. How to fix it: one single blueprint for all three; the differences, all in the .env. Parity between environments is worth more than micro-optimizing each one.

Exercises

Exercise 1 — Predict the resource names. You bring up the same docker-compose.yml under two environments with COMPOSE_PROJECT_NAME=cumbre-dev and COMPOSE_PROJECT_NAME=cumbre-prod. For the volume declared as postgres_storage, write the real name Docker is going to give it in each environment, and explain in one sentence why that guarantees the two databases don't mix.

See solution

In dev: cumbre-dev_postgres_storage. In prod: cumbre-prod_postgres_storage.

Docker prefixes the volume with the project's name, so even though the volume is declared with the same name (postgres_storage) on the same blueprint, the two environments produce two different volumes with different names. Data written to dev's database physically goes to cumbre-dev_postgres_storage, a disk drawer separate from prod's. They don't mix because, for Docker, they're different objects.

Why it works: if you predicted the names correctly, you understood the mechanism of isolation —the project prefix— which is the idea holding up the whole lesson. Isolation isn't a fragile configuration; it's an automatic consequence of running each environment under its own project name.

Exercise 2 — Classify what gets shared and what doesn't. For each element, say whether it must be different per environment or can be the same across all three, and why: (a) the docker-compose.yml; (b) N8N_ENCRYPTION_KEY; (c) the database volume; (d) the port on your machine; (e) order-triage's JSON; (f) COMPOSE_PROJECT_NAME.

See solution

(a) The same across all three: it's the common blueprint, and its sameness is what guarantees parity between environments.

(b) Different per environment: each environment encrypts its credentials with its own key, so a dev secret can't be decrypted in prod. It's a security wall.

(c) Different per environment (via the project prefix): each environment has its own isolated database. Sharing it would break data isolation.

(d) Different per environment: two containers can't listen on the same window on your machine; 5678/5679/5680.

(e) The same (the logic): order-triage gets versioned once and runs across all three. What changes is its configuration, not its logic.

(f) Different per environment: it's the "address" that isolates each stack; two environments with the same project name would step on each other.

Why it works: the key is seeing the pattern —what's shared logic (a, e) is the same; what's a physical instance or secret (b, c, d, f) is different. If you got all six right, you already have the map of what environments share and what they don't, which is what prevents cracks in the isolation.

Exercise 3 — Design the three .env.example files. Write the first three lines —COMPOSE_PROJECT_NAME, N8N_PORT, and WEBHOOK_URL— as they'd look in each of the three environments' .env.example. That's nine lines total (three per environment). Then explain why WEBHOOK_URL has to change together with the port.

See solution

For dev:

COMPOSE_PROJECT_NAME=cumbre-dev
N8N_PORT=5678
WEBHOOK_URL=http://localhost:5678/

For staging:

COMPOSE_PROJECT_NAME=cumbre-staging
N8N_PORT=5679
WEBHOOK_URL=http://localhost:5679/

For prod:

COMPOSE_PROJECT_NAME=cumbre-prod
N8N_PORT=5680
WEBHOOK_URL=http://localhost:5680/

WEBHOOK_URL has to change with the port because it's the base address n8n uses to build its workflows' webhook URLs. If the environment is reached on port 5679 but WEBHOOK_URL still says 5678, n8n would show external services a URL pointing at the wrong environment, and incoming calls would land somewhere they shouldn't, or fail. The webhook URL and the port have to tell the same story.

Why it works: writing all three at once forces you to see the differences between environments are few, orderly, and all live in the .env. And the question about WEBHOOK_URL previews a lesson 4 topic —each environment's own URLs— which is where most people slip up when separating environments.

Summary and next step

In this lesson you built the module's heart: isolation. You used the image of three buildings with the same blueprint —same layout, different installations and addresses— to understand that the three environments are born from the same docker-compose.yml but share none of their installations. You discovered the exact mechanism of isolation: Compose's project name (COMPOSE_PROJECT_NAME), which Docker uses to prefix every resource, so that cumbre-dev and cumbre-prod produce different volumes, containers, and networks by construction. You locked in the list of what's never shared —database, volumes, encryption key, credentials, ports— and the one thing that does move between environments, by hand and on purpose: the workflow's logic. You adopted the naming (cumbre-dev/staging/prod) and port (5678/5679/5680) conventions, and set up the environments/{dev,staging,prod}/ skeleton with its common blueprint, its contract .env.example, and the .gitignore shielding every .env.

Before moving on you should be able to: explain how Compose's project name produces the isolation; name five things that never get shared between environments; and say why the docker-compose.yml is the same across all three but the .env is different.

Lesson 4 furnishes that skeleton with what truly sets each environment apart: its .env. You're going to fully understand what an .env file is, generate a different N8N_ENCRYPTION_KEY per environment —and see, in detail, what breaks if you share it or change it— configure each one's own WEBHOOK_URL and host, and close out the module's security reflex: .env out of the repository, .env.example inside as the contract. It's the lesson where the keys become real.

Resources