I've seen this more than once, even on production projects. Somebody needs a test environment, so they create docker-compose.test.yml, copy the whole docker-compose.yml into it, and declare every service again from scratch.
Ctrl+C and Ctrl+V are great shortcuts, until they end up in your infrastructure. Sooner or later the copies drift apart: one file gets a Postgres upgrade, the other doesn't, and your tests happily pass against a database version you don't actually run.
Docker Compose can do much better with two features:
-
A shared base file: every service is declared once and reused with
extends. -
Profiles: one
docker-compose.ymlholds all environments, and you choose which one to start.
Add one env file per environment and a well-chosen command, and adding a new environment becomes a few lines instead of a full copy-paste.
The snippets are simplified from my side project, a flower shop backend, but the pattern works for any stack.
Contents
- The full setup
- How to run it
- The shared base file
- Profiles
- Env files
- The command key
- Try it yourself
The full setup
Here's everything at once, then we'll take it apart piece by piece. The example is a Node.js app with PostgreSQL and MailHog (a fake SMTP server that catches outgoing emails).
.
├── docker-compose.base.yml # shared service settings
├── docker-compose.yml # environments: dev and test
├── .env.dev
└── .env.test
docker-compose.base.yml
services:
app:
image: node:22.12.0
ports:
- ${PORT}:${PORT}
volumes:
- ./:/usr/src/app
working_dir: /usr/src/app
postgres:
image: postgres:17.3
ports:
- ${POSTGRES_PORT}:${POSTGRES_PORT}
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- PGPORT=${POSTGRES_PORT}
mailhog:
image: mailhog/mailhog
ports:
- ${EMAIL_SMTP_PORT}:${EMAIL_SMTP_PORT} # SMTP
- ${EMAIL_HTTP_PORT}:${EMAIL_HTTP_PORT} # web UI
environment:
- MH_SMTP_BIND_ADDR=0.0.0.0:${EMAIL_SMTP_PORT}
- MH_UI_BIND_ADDR=0.0.0.0:${EMAIL_HTTP_PORT}
- MH_API_BIND_ADDR=0.0.0.0:${EMAIL_HTTP_PORT}
docker-compose.yml
services:
dev_app:
extends:
file: docker-compose.base.yml
service: app
profiles:
- dev
env_file: .env.dev
depends_on:
- dev_postgres
- mailhog
command: sh -c "npm ci && npm run start"
test_app:
extends:
file: docker-compose.base.yml
service: app
profiles:
- test
env_file: .env.test
depends_on:
- test_postgres
- mailhog
command: sh -c "npm ci && npm test"
dev_postgres:
extends:
file: docker-compose.base.yml
service: postgres
profiles:
- dev
volumes:
- ./docker/pgdata:/var/lib/postgresql/data
test_postgres:
extends:
file: docker-compose.base.yml
service: postgres
profiles:
- test
mailhog:
extends:
file: docker-compose.base.yml
service: mailhog
profiles:
- dev
- test
.env.dev
NODE_ENV=dev
PORT=3000
JWT_SECRET=dev_secret
POSTGRES_PORT=54321
POSTGRES_DB=development
POSTGRES_USER=admin
POSTGRES_PASSWORD=postgres
POSTGRES_URL=postgresql://admin:postgres@dev_postgres:54321/development
EMAIL_HOST=mailhog
EMAIL_SMTP_PORT=1025
EMAIL_HTTP_PORT=8025
.env.test
NODE_ENV=test
PORT=3001
JWT_SECRET=test_secret
POSTGRES_PORT=54322
POSTGRES_DB=test
POSTGRES_USER=test
POSTGRES_PASSWORD=test
POSTGRES_URL=postgresql://test:test@test_postgres:54322/test
EMAIL_HOST=mailhog
EMAIL_SMTP_PORT=1025
EMAIL_HTTP_PORT=8025
How to run it
One command per environment:
# Development
docker compose --env-file .env.dev --profile dev up
# Tests
docker compose --env-file .env.test --profile test up --exit-code-from test_app
That's the whole interface. Now let's see why it works.
The shared base file
docker-compose.base.yml describes each service once, with only the settings that are the same in every environment: image, ports, volumes and basic configuration. There are no profiles, no names and no commands in it. It's not meant to be started on its own; it's a library of building blocks.
docker-compose.yml then builds the real services from those blocks with extends:
dev_postgres:
extends:
file: docker-compose.base.yml
service: postgres
profiles:
- dev
dev_postgres gets everything from the base postgres service and adds only what makes it different.
Why bother: the Postgres version, ports and settings now live in exactly one place. Upgrade the image once and every environment gets it. No more "which file is the right one?"
Tip: with extends, profiles and env files combined, it can be hard to see what Compose will actually run. docker compose config prints the final merged result with all variables filled in:
docker compose --env-file .env.dev --profile dev config
Profiles
A profile is a label on a service. When you run docker compose --profile dev up, only services labelled dev start:
| Profile | Starts |
|---|---|
dev |
dev_app, dev_postgres, mailhog
|
test |
test_app, test_postgres, mailhog
|
This is what replaces the copy-pasted docker-compose.test.yml. Both environments live in one file, side by side, and each one only adds what is actually different.
A few decisions worth explaining:
-
Postgres is per environment.
dev_postgresmounts a folder, so my dev data survives restarts.test_postgreshas no volume on purpose, so every test run starts with a clean database. -
MailHog is shared. It's listed in both profiles,
devandtest. It doesn't care which environment sends an email; it catches everything and judges nobody.
One gotcha: a service without a profiles key starts with every profile. So give every service a profile, or you'll wonder why your test run also started something it shouldn't.
Env files
Each profile gets its own env file: .env.dev for dev and .env.test for test. The compose files stay the same; only the values change.
The variable names are identical in every environment. Dev and test both have PORT, JWT_SECRET, POSTGRES_URL and so on, just with different values:
| Variable | .env.dev | .env.test |
|---|---|---|
PORT |
3000 | 3001 |
JWT_SECRET |
dev_secret | test_secret |
POSTGRES_PORT |
54321 | 54322 |
POSTGRES_DB |
development | test |
So the structure of your config and secrets never changes, and your app never has to ask "which environment am I in?". It reads process.env and gets on with life.
A new environment is a new env file plus a small edit. Want staging? Create .env.staging, add a staging_app and staging_postgres that extend the base services, and give them a staging profile. That's it.
--env-file and env_file are two different things. They look almost the same but do different jobs:
-
--env-file .env.devon the command line fills in the${...}placeholders inside the compose files, like ports and passwords. -
env_file: .env.devinside a service passes the variables into the container, so the app can read them fromprocess.env.
In this setup the app uses the same file for both, so everything about an environment lives in one place.
The same port inside and outside. A line like ${POSTGRES_PORT}:${POSTGRES_PORT} maps the host port to the same port inside the container. For that to work, the service has to listen on that port too, which is what PGPORT does for Postgres and the MH_*_BIND_ADDR variables do for MailHog. The result: one number per service, and dev and test never point at the same database.
Keep the real env files out of git. They hold secrets, and your future self will thank you.
The command key
command overrides what a container runs on start, and it's what lets one image do different jobs.
Both dev_app and test_app use the plain node image with the source code mounted in. No Dockerfile, no custom image to rebuild. The only difference is the command:
-
dev_apprunsnpm ci && npm run startand keeps the server running. -
test_apprunsnpm ci && npm test, then exits.
Two small things matter here:
-
Use
&&, not;. Withnpm ci; npm test, the tests still run after a failed install and fail with a confusing error.&&stops at the first failure. -
--exit-code-from test_appstops everything when the tests finish and returns their exit code. Your terminal (or CI) gets a clear pass or fail.
Also, depends_on only controls the start order. It doesn't wait until Postgres is actually ready to accept connections. In practice npm ci is slow enough that Postgres is ready long before it finishes (finally, a use for slow installs). For a bulletproof setup, add a Postgres healthcheck and use condition: service_healthy.
Try it yourself
- Take any Node.js project with
startandtestscripts inpackage.json. - Add the four files from The full setup, and add
.env.devand.env.testto.gitignore. - Start development:
docker compose --env-file .env.dev --profile dev up
Your app is on http://localhost:3000, and every email it sends shows up at http://localhost:8025.
- Run the tests:
docker compose --env-file .env.test --profile test up --exit-code-from test_app
- Clean up when you're done:
docker compose --env-file .env.dev --profile dev --profile test down
A full working example lives in my repo: kot-1999/flowers_shop_be.
Copy-paste is still allowed. Just not for your infrastructure. 😉
How do you handle multiple environments with Docker Compose? Let me know in the comments.
Top comments (0)