DEV Community

Cover image for The Multi-Scenario Docker Pattern: how to build a reproducible Docker environment for any conditions
David Evdoshchenko
David Evdoshchenko

Posted on • Edited on

The Multi-Scenario Docker Pattern: how to build a reproducible Docker environment for any conditions

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
  • .env
  • Makefile
  • devcontainer.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
└── ...
Enter fullscreen mode Exit fullscreen mode

Execution Flow

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or

cd .devcontainer/scenario-embedded
make deploy
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Deploy:

cd .devcontainer/scenario-embedded
make deploy
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)