1. Introduction: Breaking Free from “It Works on My Machine”
In the field of software development, the “It works on my machine” problem, caused by differences in developers’ environments, has long been a factor in wasting time on many projects. Local environments are constantly exposed to “state uncertainty,” such as OS differences, installed language versions, library dependencies, and conflicts between globally installed tools.
What fundamentally solves these issues is container technology like Docker and the Infrastructure as Code (IaC) paradigm. By containerizing the local development environment, OS-level isolation is achieved, and the environment itself can be version-controlled alongside the codebase.
In this article, we will thoroughly explain the steps to build a “reproducible local development environment that results in the exact same state, no matter who, when, or on what machine it is launched,” by leveraging Docker, Docker Compose, and VSCode DevContainers. We will also explore the deep technical mechanisms behind it from a mathematical perspective.
2. The Synergy Between Infrastructure as Code (IaC) and Container Technology
IaC Principles and Application to Local Environments
Infrastructure as Code (IaC) is an approach to managing infrastructure configuration and provisioning through machine-readable definition files rather than manual processes. The core principles of IaC include the following elements:
- Declarative Approach: Defines “what the final state should be” rather than “how to change the state.”
- Idempotency: Guarantees the exact same result (state) no matter how many times the script is executed.
- Version Control: The infrastructure state is stored as code in a VCS like Git, enabling change history tracking and peer reviews.
Practicing IaC in a local development environment means codifying the “ideal state” of the development environment using Dockerfile, docker-compose.yml, and devcontainer.json. This provides an onboarding experience where new team members can clone the repository and start developing immediately by running a single command.
Kernel Features Supporting Container Technology
Unlike hypervisor-based virtualization like virtual machines (VMs), container technology is a lightweight virtualization technique that isolates processes while sharing the host OS kernel. To achieve this, it primarily relies on the following Linux kernel features:
- Namespaces: Provides independent views of system resources (PID, network, mount points, users, etc.) for each process.
- Cgroups (Control Groups): Limits and allocates the physical resources (CPU, memory, disk I/O, etc.) that processes can use.
- UnionFS (Union File System): A technology that transparently overlays multiple directory trees (layers) to present them as a single file system. Docker’s image layers rely on this technology.
Let’s consider a mathematical model of resource limitation. Let the total memory capacity of the host machine be $M_{\text{total}}$, and the memory limit of $n$ containers running on the host be $m_i$. Taking into account the base memory $M_{\text{os}}$ consumed by the host OS and other processes, the necessary condition for the system to operate stably can be expressed by the following inequality:
$$ \sum_{i=1}^{n} m_i \le M_{\text{total}} - M_{\text{os}} $$By strictly defining $m_i$ for each container using Cgroups, even if a specific container causes a memory leak, the OOM (Out Of Memory) Killer can prevent other containers or the entire host system from going down.
3. Efficient Dockerfile Design: Mastering Multi-Stage Builds
The first step to a reproducible environment is designing the Dockerfile that defines the application’s runtime environment. Here, using Python (FastAPI) as an example, we will explain the best practices for a secure and lightweight Dockerfile leveraging multi-stage builds.
A multi-stage build is a technique that uses multiple FROM instructions within a single Dockerfile to separate the build environment (a heavy environment containing compilers and development tools) from the runtime environment (a lightweight environment holding only the necessary artifacts).
Practical Python FastAPI Dockerfile
The following code is an example of an advanced Dockerfile that combines dependency management using Poetry and multi-stage builds.
| |
Mathematical Evaluation of Image Size via Multi-Stage Builds
Let the image size when built with a single stage be $S_{\text{single}}$, and the image size when a multi-stage build is applied be $S_{\text{multi}}$. The size reduction rate $R$ is calculated as follows:
$$ R = \left( 1 - \frac{S_{\text{multi}}}{S_{\text{single}}} \right) \times 100 \ (\%) $$For example, suppose $S_{\text{single}}$ includes the OS base image (approx. 110MB), development packages (like gcc, approx. 150MB), Poetry itself (approx. 40MB), project dependency libraries (approx. 80MB), and source code (approx. 5MB), totaling 385MB. On the other hand, in $S_{\text{multi}}$, only the dependency libraries (80MB) and source code (5MB) are copied into the base image (110MB), resulting in a total of 195MB.
$$ R = \left( 1 - \frac{195}{385} \right) \times 100 \approx 49.35\% $$In this way, introducing multi-stage builds can reduce the image size by about half. Reducing the image size directly leads to improved security by shortening pull times from the registry, saving disk space, and shrinking the attack surface.
4. Orchestrating Multiple Containers with Docker Compose
In modern web application development, a microservices architecture where multiple components like web servers, databases, and cache servers collaborate is common. We use docker-compose.yml to centrally manage these in a local environment.
Here, we will build a 3-tier system locally consisting of “Web (FastAPI),” “Database (PostgreSQL),” and “Cache (Redis).”
Architecture Diagram (Mermaid)
The following diagram is a block diagram illustrating the relationships among each container, network, and volume on the local machine.
graph TD
User["Host Machine (Browser/curl)"] -->|Localhost:8000| Web["FastAPI Web Container"]
subgraph "Docker Bridge Network (app-network)"
Web -->|Port 5432| DB["PostgreSQL Container"]
Web -->|Port 6379| Redis["Redis Container"]
end
DB --> Volume1["Named Volume (postgres_data)"]
Redis --> Volume2["Named Volume (redis_data)"]
HostDir["Host Source Code (./src)"] -.->|Bind Mount| Web
Implementation and Detailed Explanation of docker-compose.yml
Below is an example of a robust docker-compose.yml that can withstand practical environment construction.
| |
Volumes and Data Persistence
Containers are generally “stateless” and “ephemeral” entities. When a container is destroyed, the data inside it is also lost. To retain database data and caches, it is necessary to mount an area of the host machine’s file system into the container.
- Bind Mount: This corresponds to
./src:/app/src:roin thewebservice above. It directly maps a specific directory on the host into the container. This is used to immediately reflect local code edits in the container (hot reloading). For security reasons, it is a best practice to add the:ro(Read-Only) option to prevent the container from altering the host’s source code. - Named Volume: This corresponds to
postgres_dataandredis_data. This is an area internally managed by Docker (like/var/lib/docker/volumes/), which offers better I/O performance than bind mounts and abstracts the differences in file systems across OSes. Be sure to use this for database persistence.
Networking and Service Discovery
Docker Compose creates a unique bridge network for each project by default. This is the app-network mentioned above.
Containers belonging to the same network can resolve names (DNS resolution) using the “service name” (e.g., db, redis) as the hostname instead of an IP address.
For example, the Web container can access the database using the URL postgresql://postgres:password@db:5432/mydb. This allows connections to be switched transparently via environment variables, regardless of whether it’s a local or production environment.
Health Checks and Controlling Startup Order
The depends_on directive controls the startup order of containers, but simply specifying depends_on will start the Web container as soon as the “DB container has started.” In reality, the DB initialization process (starting the PostgreSQL process and preparing tables) takes several seconds, so connections from the Web container might fail.
To prevent this, you can define a healthcheck and specify condition: service_healthy, which ensures the Web container starts only after confirming that “the DB is ready to accept connection requests.”
5. Environment Variable Management and Security (.env)
Hardcoding sensitive information, such as database passwords and API keys, into docker-compose.yml is an anti-pattern that must be strictly avoided. Instead, inject these values using an environment variable file .env.
Create a .env file in the project root.
| |
By default, Docker Compose reads the .env file in the execution directory and expands placeholders like ${VAR_NAME} in the YAML file. This method makes it possible to safely manage different configuration values for various environments like local, staging, and production without altering the infrastructure code.
6. The Ultimate Development Experience with VSCode DevContainers
So far, we have built a robust backend environment using Docker. However, we can take it a step further. By using the VSCode DevContainers (Remote - Containers) feature, you can run the backend of the editor (VSCode) itself inside the container.
This eliminates the need to install Python or Node.js on your local machine, allowing everything from linters (flake8/eslint) and formatters (black/prettier) to IDE extensions to be defined within the codebase and shared with the entire team.
Configuring devcontainer.json
Create a .devcontainer directory in the project root and place the configuration file inside it.
.devcontainer/devcontainer.json:
| |
By including this file in the repository, a “Reopen in Container” prompt will appear the moment you open the project in VSCode. A single click will spin up all the necessary containers, install the extensions, and make it instantly ready for coding. It’s truly a magical experience.
7. Request Processing Sequence and Performance Modeling
We will review the request processing lifecycle of the web application in our newly built local development environment using a sequence diagram and examine the mathematical model of its performance.
Sequence Diagram (Request Flow)
sequenceDiagram
participant Client as "Browser / VSCode"
participant Web as "FastAPI (Web)"
participant Redis as "Redis Cache"
participant DB as "PostgreSQL"
Client->>Web: "GET /api/users/123"
activate Web
Web->>Redis: "Check Cache for user:123"
activate Redis
alt "Cache Hit (Data exists)"
Redis-->>Web: "Return Cached User Data"
Web-->>Client: "200 OK (Fast Response)"
else "Cache Miss (Data does not exist)"
Redis-->>Web: "Null (Not Found)"
deactivate Redis
Web->>DB: "SELECT * FROM users WHERE id = 123"
activate DB
DB-->>Web: "Return Database Row"
deactivate DB
Web->>Redis: "SET user:123 Data (TTL: 60s)"
activate Redis
Redis-->>Web: "OK"
deactivate Redis
Web-->>Client: "200 OK (Standard Response)"
end
deactivate Web
Mathematical Model of Processing Latency
We mathematically model the average request processing time $T_{\text{total}}$ in the above system. We define the latency of each process as follows:
- $T_{\text{net}}$: Network latency between the client and the Web container
- $T_{\text{app}}$: Pure processing time on the application side (serialization, etc.)
- $T_{\text{cache}}$: Time required to read/write from/to Redis
- $T_{\text{db}}$: Time required to execute queries on PostgreSQL
- $p_{\text{miss}}$: Cache miss rate ($0 \le p_{\text{miss}} \le 1$)
At this time, the average response time is represented by the following expected value formula:
$$ T_{\text{total}} = T_{\text{net}} + T_{\text{app}} + T_{\text{cache}} + p_{\text{miss}} \times (T_{\text{db}} + T_{\text{cache\_write}}) $$In a local development environment (inside Docker), $T_{\text{net}}$ is close to 0, but what’s noteworthy is the I/O performance during bind mounts. Especially when using Docker Desktop on Windows/macOS, the file sharing overhead between the host OS and the VM (container) tends to bloat $T_{\text{app}}$ (such as code load time). To eliminate this bottleneck, it is highly recommended to use the aforementioned DevContainers to place the entire source code inside a named volume, or to adopt an architecture that runs the Docker engine natively on a WSL2 (Windows Subsystem for Linux 2) environment.
8. Performance Optimization of Docker Builds: Layer Caching Strategy
When writing a Dockerfile, your understanding of the “layer cache” mechanism will drastically change build times.
Docker creates file system differences (layers) for each instruction in a Dockerfile (like FROM, RUN, COPY) and holds them as caches. On rebuild, cached layers that haven’t changed are reused.
The critical principle is to “write instructions in order from the least frequently changed to the most frequently changed.”
Let’s model the impact of source code changes on build time. Let the total build time be $T_{\text{build}}$, the execution time of each step be $T_{\text{layer}_i}$, and the presence or absence of a cache hit be a boolean value $c_i \in \{0, 1\}$ (1 for a cache hit).
$$ T_{\text{build}} = T_{\text{init}} + \sum_{i=1}^{n} (1 - c_i) \times T_{\text{layer}_i} $$Once a cache miss ($c_k = 0$) occurs at layer $k$, caches for all subsequent layers $j > k$ are invalidated ($c_j = 0$).
| |
In the above case, changing even one line of code causes the first COPY to miss the cache, resulting in the time-consuming RUN poetry install being executed every time.
| |
If written this way, even if the source code changes, the layer cache for poetry install ($c_i = 1$) remains effective, drastically reducing build time from several minutes to just a few seconds.
9. Troubleshooting and Tips
Here are common problems encountered during local environment operations and their solutions.
Port Conflict Error If you get an error like
Bind for 0.0.0.0:8000 failed: port is already allocated, another process on your local machine is using that port. You can avoid this by changing the port number on the host side, likeports: - "8080:8000".Disk Space Exhaustion If you use Docker for a long period, unused images and volumes (Dangling Images / Volumes) can accumulate and consume tens of gigabytes of disk space. It is recommended to periodically clean up the system with the following command:
1docker system prune -a --volumesFile Permission Issues When using bind mounts in a Linux environment, files created inside the container may be owned by
root, preventing you from editing them on the host side. You can resolve this issue by creating a non-root user in your Dockerfile and matching their UID/GID to your own on the host OS (e.g., 1000:1000).
10. Conclusion: Accelerated Development Speeds Brought by Reproducibility
By combining Docker, Docker Compose, and VSCode DevContainers, a robust local development environment is achieved, resulting in “the exact same state no matter who launches the environment.”
Bringing the IaC paradigm into your local environment goes beyond merely reducing initial setup times. It eliminates anxiety regarding infrastructure configuration changes, facilitates experimenting with new tech stacks, enables smooth transitions to CI/CD pipelines, and dramatically improves the speed and quality of the entire development cycle.
By leveraging the best practices explained in this article—optimizing image sizes with multi-stage builds, controlling dependencies with health checks, and writing Dockerfiles with layer caching in mind—we highly encourage you to introduce the best Developer Experience (DX) to your own projects.
