We had CI passing while production kept breaking.
The application ran inside a highly restricted government network, where accessing production sometimes required physical presence in a ministry office in another country.
If something went wrong, debugging was rarely direct. In many cases it meant traveling on-site, passing security checks, and accessing internal workstations just to inspect logs or confirm behavior.
When that wasn’t possible, we were forced into guesswork: comparing environments, reproducing issues locally, and assuming configuration drift.
This is where we first encountered a structural problem: environment drift was no longer theoretical — it was operational reality.
At some point, we asked for a full specification of the production runtime (PHP version, extensions, OS packages, configuration).
We containerized the system and standardized delivery as a fully built Docker image matching production exactly.
From that point on, the real problem became clear: keeping development, CI, and production environments from silently diverging.
The Principle: one runtime, many scenarios
This pattern is built on a simple idea:
one runtime + multiple deployment scenarios
The Dockerfile and the base environment must be singular. Differences are only allowed at the scenario level. When all scenarios share one runtime and one set of common configurations, divergence between environments becomes explicit rather than accidental.
A scenario is not just a compose file. It is a self-contained unit for launching an environment, consisting of:
docker-compose.yml.envMakefiledevcontainer.json- additional scripts
Project Structure
my-app/
├── .devcontainer/
│ ├── _configs/ # shared runtime configurations
│ ├── _scripts/ # shared entrypoint scripts
│ ├── _data/ # auxiliary binary dependencies
│ ├── scenario-mapped/ # local development (bind mount)
│ │ ├── docker-compose.yml
│ │ ├── devcontainer.json
│ │ ├── Makefile
│ │ └── .env
│ ├── scenario-embedded/ # deploy image (code inside the image)
│ │ ├── docker-compose.yml
│ │ ├── devcontainer.json
│ │ ├── Makefile
│ │ └── .env
│ ├── Dockerfile.app
│ ├── Dockerfile.database
│ └── Dockerfile.*
├── .env # base application configuration
└── ...
Execution Flow
Different scenarios share the same runtime and the same set of common resources. Scenarios do not create different systems — they only switch the way the same system is launched.
Working Example
All files and scenarios shown in this article are available in a separate repository:
https://github.com/outcomer/multi-scenario-docker-pattern
The repository contains a fully working example with the directory structure, Dockerfiles, docker-compose, Makefile, and Dev Container support.
How It Works
One base runtime
All scenarios use the same set of Dockerfiles. For example, Dockerfile.app might contain:
- PHP runtime;
- extensions;
- Composer;
- system dependencies;
- base web server configuration.
This layer does not depend on any scenario.
Scenarios differ only in how they launch
Scenarios define: how code gets into the container, which environment variables are used, which tools are enabled.
scenario-mapped
Local development: code is mounted via bind mount, changes are reflected instantly, fast dev cycle, convenient for debugging.
scenario-embedded
Production / CI:
- code is copied inside the image;
- the container does not depend on the host;
- the environment is reproducible anywhere.
Why This Matters
The key idea of the pattern:
consistency is achieved not by team discipline, but by project structure
All scenarios use one runtime and one set of configurations from _configs. This means:
- differences become explicit;
- runtime versions do not drift silently;
- changes are automatically applied to all scenarios.
Note: this does not make drift impossible, but it makes it visible and controllable.
Why Not Docker Compose Profiles
Docker Compose Profiles allow enabling and disabling services within a single compose file. For simple cases this is enough. However, profiles solve only one problem — managing the set of services.
In this approach, a scenario is a broader concept. It includes not just a compose file, but also:
-
.env; -
Makefile; -
devcontainer.json; - additional scripts;
- environment launch rules.
A scenario is a complete environment, not just a service toggle.
Why Separate Scenario Directories
The typical path of project evolution often looks like this:
docker-compose.yml
↓
docker-compose.dev.yml
↓
docker-compose.prod.yml
↓
docker-compose.staging.yml
Over time this becomes a system of overrides:
docker compose \
-f docker-compose.yml \
-f docker-compose.override.yml \
-f docker-compose.staging.yml \
up
Understanding what will actually end up in the final configuration becomes increasingly difficult. In the scenario approach, launching looks like this:
cd .devcontainer/scenario-mapped
make up
or
cd .devcontainer/scenario-embedded
make deploy
A scenario becomes a physical object in the repository, not a combination of flags and files.
CI Is Just Another Scenario
An interesting side effect of this approach is that CI stops being a separate world. Instead of special logic inside GitHub Actions or GitLab CI, the pipeline can use the same scenario as the rest of the environments. Local development, CI, and production start using the same launch model. The fewer differences between these environments, the less likely you are to get surprises after deployment.
Podman Support
The pattern is not tied to Docker Engine. Since scenarios describe the way of launching rather than a specific container engine, the same set of scenarios can be used with both Docker and Podman through a compatible interface. Differences remain at the level of the launch environment, not the project architecture.
Practical Usage
Local development:
cd .devcontainer/scenario-mapped
make up
Deploy:
cd .devcontainer/scenario-embedded
make deploy
Makefile Scenario Example
A full Makefile typically contains dozens of commands. To understand the pattern, it is enough to see the core idea:
deploy:
@$(MAKE) build
@$(MAKE) migrations-check
@$(MAKE) up
@$(MAKE) migrations-run
@$(MAKE) healthcheck
The scenario encapsulates the knowledge of exactly how a launch or deployment should happen. The user only needs to call one command.
What the Pattern Guarantees
The pattern provides:
- a single runtime for all scenarios;
- no hidden differences between dev and prod;
- explicit separation of launch scenarios;
- isolation of dev tools from production;
- centralized configuration through a shared layer.
Limitations
The pattern does not eliminate the need for architectural discipline. It is still important to:
- not add scenario-specific logic to the shared runtime;
- not duplicate configurations outside
_configs; - not mix dev and production behavior inside a Dockerfile.
Summary
Most problems in Docker projects do not appear because of containers — they appear because of multiple nearly identical ways of launching the same application. The Multi-Scenario Docker Pattern addresses exactly this problem: instead of several gradually diverging environments, you get one runtime and several explicit scenarios for using it.
This pattern emerged from a real production constraint, not a theoretical design exercise.
DON'T CHANGE THE ENVIRONMENT — CHANGE THE SCENARIO
Working Example
All files and scenarios shown in this article are available in a separate repository:
https://github.com/outcomer/multi-scenario-docker-pattern
The repository contains a fully working example with the directory structure, Dockerfiles, docker-compose, Makefile, and Dev Container support.
What’s your go-to pattern for keeping Docker behavior consistent across environments (e.g. dev, CI, deploy, others)? I’d love to hear what has actually worked for you in the comments.

Top comments (0)