I used to rebuild my Docker image every time I fixed a typo. A one-line change meant waiting 2-3 minutes for Docker to rebuild layers, reinstall dependencies, and restart the container. I thought this was just the cost of containerized development.
Then I discovered runtime containers. Same isolated environment, same reproducibility, but code changes reflect instantly. No rebuilds. No waiting. The runtime lives in the container, the code lives on the host.
This pattern isn’t new, but it’s criminally underused. Most developers either suffer through slow rebuild cycles or abandon containers for local development entirely. Both approaches miss the point: you can have containerized consistency AND development speed.
This guide covers the what, why, and how of runtime containers. When they’re perfect, when they’re terrible, and how to build production-quality containerized development workflows.
Why most containerized development is painfully slow
Consider a typical Python web application. Your Dockerfile looks like this:
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install -r requirements.txt
# Copy application code
COPY . .
# Run the application
CMD ["python", "app.py"]
This works. But every code change requires:
- Rebuild the image (
docker build -t myapp .) - Stop the old container
- Start a new container from the new image
- Wait for the application to initialize
Even with layer caching, this takes 30 seconds to 2 minutes. Fix a typo, wait 90 seconds. Adjust a log statement, wait 90 seconds. You’re spending more time waiting than coding.
The standard “solution” is to use bind mounts in development:
docker run -v $(pwd):/app myapp
But developers rarely do this correctly. They mount code over an image that already has code baked in. The image is still bloated with stale code. Layer caching is still broken by code changes. The workflow is still hybrid and confusing.
What is a runtime container?
A runtime container is an image that contains everything needed to execute your code, except the code itself.
A runtime container contains:
- The language runtime (Python interpreter, Node.js, JVM, etc.)
- System dependencies (libraries, build tools)
- Application dependencies (pip packages, npm modules)
A runtime container does NOT contain:
- Your application source code
- Configuration files that change frequently
- Data or state
Your code lives on the host machine. You mount it into the container when you run it.
1+----------------------------------------------------------+
2| Docker Container |
3| |
4| +------------------+ +-----------------------+ |
5| | Runtime & Deps | +---> | Mounted: /app/src | |
6| | - Python 3.11 | | | (from host filesystem)| |
7| | - pip packages | | +-----------------------+ |
8| | - system libs | | |
9| +------------------+ | +-----------------------+ |
10| +---> | Mounted: /app/config | |
11| | (from host filesystem)| |
12| +-----------------------+ |
13+----------------------------------------------------------+
14 ^
15 |
16 Code lives on host, mounted into container at runtimeCreated with asciiflow
This architecture gives you:
- Instant code changes: Edit files on your host, changes reflect immediately in the container
- Fast rebuilds: Only rebuild the runtime image when dependencies change, not on every code change
- Familiar workflow: Use your local IDE, debugger, and tools
- True isolation: Runtime is containerized, ensuring consistency across team members
Level 1: Your first runtime container
Let’s build a runtime container for a Python Flask application.
Traditional approach (what you’re probably doing, what I was doing):
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . . # This bakes your code into the image
CMD ["python", "app.py"]
Every time you change app.py, you rebuild the entire image.
Runtime container approach:
FROM python:3.11-slim
WORKDIR /app
# Install dependencies (this rarely changes)
COPY requirements.txt .
RUN pip install -r requirements.txt
# DO NOT COPY SOURCE CODE
# Code will be mounted at runtime
CMD ["python", "src/app.py"]
Notice what’s missing? No COPY . .. Your code isn’t baked in.
Build the runtime image once:
docker build -f Dockerfile.runtime -t myapp-runtime .
Run it with your code mounted:
docker run -v $(pwd)/src:/app/src myapp-runtime
Now edit src/app.py on your host machine. If you’re using a framework with auto-reload (Flask’s debug mode, FastAPI with --reload), changes appear instantly. Zero rebuild time.
Node.js example
The same pattern works for any language runtime.
FROM node:18-slim
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm ci --only=production
# DO NOT COPY SOURCE CODE
CMD ["node", "src/server.js"]
Run with mounted code and nodemon for hot-reloading:
docker run \
-v $(pwd)/src:/app/src \
-e NODE_ENV=development \
myapp-runtime \
npx nodemon src/server.js
Edit src/server.js, nodemon detects the change, restarts the process. Entire cycle takes milliseconds.
Level 2: Docker Compose workflows
Managing mount paths and environment variables in docker run commands gets tedious with more than 2-3 containers. Docker Compose makes managing multiple containers and their configurations easy for development.
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.runtime
volumes: # Mount source code - ./src:/app/src:ro - ./config:/app/config:ro
environment: - FLASK_ENV=development - FLASK_DEBUG=1
ports: - "5000:5000"
command: flask run --host=0.0.0.0
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: myapp
volumes: - postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Start the entire stack:
docker compose up
Your application runs in a container with the Python runtime and dependencies. Your database runs in its own container. Your code is mounted from the host. Edit code, refresh browser. That’s it.
Read-only mounts for safety
Notice :ro on the volume mounts? This makes them read-only from inside the container. Your application can’t accidentally modify source files. For development this isn’t strictly necessary, but it’s good practice.
If your app needs to write files (logs, uploads, generated assets), mount those directories separately as read-write:
volumes:
- ./src:/app/src:ro
- ./config:/app/config:ro
- ./logs:/app/logs # Read-write for log output
- ./uploads:/app/uploads # Read-write for user uploads
Environment-specific configuration
Development and production often need different settings. Use environment variables to control behavior:
services:
app:
environment: - FLASK_ENV=development - DATABASE_URL=postgresql://postgres:devpassword@db:5432/myapp - LOG_LEVEL=DEBUG - ENABLE_DEBUGGER=1
Or load from an .env file:
services:
app:
env_file: - .env.development
Level 3: Advanced patterns
Handling compiled dependencies
Some languages need to compile dependencies (C extensions in Python, native modules in Node.js, cli-tools like ffmpeg). These dependencies are platform-specific. If you’re on macOS but deploying to Linux, the compiled cli-tools won’t work as is. E.g. on ubuntu 22.04, you can’t use the latest ffmpeg (>v4) from apt. macOS brew has the latest version..
Solution: Compile dependencies inside the container, store them in a named volume.
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for compilation
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/\*
COPY requirements.txt .
RUN pip install -r requirements.txt
CMD ["python", "src/app.py"]
services:
app:
volumes: - ./src:/app/src:ro # Anonymous volume to prevent host node_modules from overriding container's - /app/node_modules
The anonymous volume /app/node_modules ensures the container uses its own compiled modules, not your host’s.
Multi-stage builds for dev and production
You can use a single Dockerfile for both development (runtime container) and production (baked-in code).
FROM python:3.11-slim AS base
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install -r requirements.txt
# Development stage: no code, expects it to be mounted
FROM base AS development
CMD ["flask", "run", "--host=0.0.0.0", "--reload"]
# Production stage: code baked in
FROM base AS production
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
services:
app:
build:
context: .
target: development # Use the development stage
volumes: - ./src:/app/src:ro
Development: docker compose up (uses mounted code from development stage)
Production: docker build --target production -t myapp . (bakes code into production stage)
One Dockerfile, two workflows. Clean separation of concerns.
Debugging inside containers
Runtime containers make debugging easier because you’re using your local IDE and tools. But sometimes you need to debug inside the container itself.
Python with debugpy:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
# Install debugger in development
RUN pip install debugpy
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "--wait-for-client", "src/app.py"]
services:
app:
ports: - "5000:5000" - "5678:5678" # Debugger port
Connect your IDE’s debugger to localhost:5678. Set breakpoints in your local files. The debugger works because the code is mounted from the host.
Watching for dependency changes
You update requirements.txt but forget to rebuild the runtime image. Your app crashes with “ModuleNotFoundError”. This is frustrating.
Solution 1: Document the workflow
Add clear instructions in your README:
1## Development Workflow
2
3- Edit code in `src/` -> changes reflect instantly
4- Change dependencies in `requirements.txt` -> run `docker compose build app`
5- Change system packages in Dockerfile -> run `docker compose build app`Solution 2: Automate with Make
.PHONY: dev rebuild deps
dev:
docker compose up
rebuild:
docker compose build
docker compose up
deps: rebuild # Alias for clarity when you add dependencies
Usage:
make dev- start developmentmake deps- rebuild and restart after changing dependencies
Solution 3: File watchers
Use a file watcher to automatically rebuild when dependency files change:
#!/bin/bash
# Requires entr: brew install entr (macOS) or apt-get install entr (Linux)
ls requirements.txt package.json | entr -r docker compose build
Run this in a separate terminal. When requirements.txt or package.json changes, it rebuilds automatically.
Common mistakes and how to avoid them
Mistake 1: Mounting too much
Don’t mount your entire project directory:
# BAD: Mounts everything, including .git, node_modules, **pycache**
volumes:
- .:/app
This clutters the container with development artifacts, breaks package management, and causes permission issues.
Solution: Mount only what you need.
# GOOD: Mount only source and config
volumes:
- ./src:/app/src:ro
- ./config:/app/config:ro
Add a .dockerignore file to prevent accidental copies during builds:
**/**pycache**
**/_.pyc
\*\*/.pytest_cache
node_modules
.git
.env
.vscode
.idea
_.log
Mistake 2: Ignoring file permissions
On Linux, files created by the container might be owned by root, making them uneditable on the host.
Solution: Run the container as your user.
docker run -u $(id -u):$(id -g) -v $(pwd)/src:/app/src myapp
Or set the user in docker compose.yml:
services:
app:
user: "${UID:-1000}:${GID:-1000}"
volumes: - ./src:/app/src
Mistake 3: Forgetting to rebuild when dependencies change
You add a new package to requirements.txt but forget to rebuild the runtime image. Your app crashes with “ModuleNotFoundError”.
This is the single most common mistake with runtime containers. The container has an old snapshot of your dependencies.
Solution: Make rebuilds obvious. Use clear naming conventions, add checks, or automate.
Add a check script that compares dependency files:
#!/bin/bash
if [ requirements.txt -nt .docker-built ] || [ ! -f .docker-built ]; then
echo "requirements.txt changed since last build"
echo "Run: docker compose build"
exit 1
fi
Update your Makefile to track builds:
.PHONY: dev rebuild
dev: .docker-built
docker compose up
rebuild:
docker compose build
touch .docker-built
docker compose up
.docker-built: requirements.txt
docker compose build
touch .docker-built
Mistake 4: Using runtime containers in CI/CD
Your CI pipeline should NOT mount code into containers. It should build complete, testable images that mirror production.
# BAD: Mounting code in CI
- name: Test
run: docker run -v $(pwd):/app myapp-runtime pytest
# GOOD: Build a complete image for testing
- name: Build test image
run: docker build --target production -t myapp:test .
- name: Test
run: docker run myapp:test pytest
CI should mimic production as closely as possible. Runtime containers are for development velocity, not deployment.
Mistake 5: Mixing development and production configurations
Developers sometimes accidentally deploy development images or use development settings in production.
Solution: Use clear naming and separate files.
1Dockerfile # Production image (code baked in)
2Dockerfile.runtime # Development runtime (no code)
3docker compose.yml # Development environment
4docker compose.prod.yml # Production environmentNever deploy anything with .runtime or development in the name.
When NOT to use runtime containers
Runtime containers aren’t universally better. They have specific use cases.
Use runtime containers when:
- You’re actively developing and iterating on code
- You want instant feedback without rebuilds
- Your team uses different operating systems
- You need consistent development environments
Don’t use runtime containers when:
- Building for production (bake code in)
- Running CI/CD tests (build complete images)
- Code and dependencies change together frequently
- You’re working on a compiled language with slow compilation (Go, Rust, C++)
For compiled languages, the compilation step is the bottleneck, not Docker builds. Runtime containers don’t help much there.
Real-world example: Polyglot microservices
Here’s a complete setup for a Python API backend + Node.js frontend, both using runtime containers.
version: '3.8'
services:
# Python API backend
api:
build:
context: ./backend
dockerfile: Dockerfile.runtime
volumes: - ./backend/src:/app/src:ro - ./backend/tests:/app/tests:ro
environment: - DATABASE_URL=postgresql://postgres:password@db:5432/myapp - FLASK_ENV=development - FLASK_DEBUG=1
ports: - "5000:5000"
depends_on: - db
command: flask run --host=0.0.0.0
# Node.js frontend
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.runtime
volumes: - ./frontend/src:/app/src:ro - ./frontend/public:/app/public:ro - /app/node_modules # Anonymous volume for dependencies
environment: - NODE_ENV=development - REACT_APP_API_URL=http://localhost:5000
ports: - "3000:3000"
command: npm start
# PostgreSQL database
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
volumes: - postgres_data:/var/lib/postgresql/data
ports: - "5432:5432"
volumes:
postgres_data:
backend/Dockerfile.runtime:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["flask", "run", "--host=0.0.0.0"]
frontend/Dockerfile.runtime:
FROM node:18-slim
WORKDIR /app
COPY package\*.json ./
RUN npm ci
CMD ["npm", "start"]
Start everything:
docker compose up
The workflow:
- Edit Python code -> Flask auto-reloads
- Edit React code -> Webpack auto-reloads
- Add Python dependency ->
docker compose build api && docker compose up - Add Node dependency ->
docker compose build frontend && docker compose up
Both services have isolated runtimes. Both reload instantly on code changes. Both share the same database. The entire stack runs with one command.
The mental model
The key to using runtime containers effectively is changing how you think about containers.
Traditional Docker thinking:
Container = snapshot of entire application (code + runtime + dependencies)
Runtime container thinking:
Container = runtime environment, code is dynamic
This mental changes your development approach:
- Don’t rebuild for code changes: Rebuilds are only for runtime/dependency changes
- Separate concerns: Runtime stability vs code iteration are different problems
- Development ≠ Production: Use different strategies for different environments
- Mount, don’t copy: During development, mount everything you’re actively changing
The goal is to maintain containerized consistency without sacrificing development velocity. You want the isolation and reproducibility of containers with the speed of local development.
Runtime containers solve a specific problem: containerized development is slow. By separating the runtime from the code, you get the best of both worlds: isolated, reproducible environments and instant feedback loops.
This isn’t a replacement for production container builds. It’s a development accelerator. Use runtime containers to iterate quickly during active development. Build complete, immutable images for testing and deployment.
The workflows in this guide are battle-tested patterns from real projects: Python APIs processing thousands of requests, Node.js frontends with hot module replacement, polyglot microservices running in Docker Compose. They work because they respect the separation of concerns: runtime in the container, code on the host, clear boundaries between development and production.
If you’re rebuilding your Docker image every time you change a line of code, you’re doing it wrong. Adopt runtime containers. Your feedback loop will thank you.
Further Reading
- Docker Volumes vs Bind Mounts - Official Docker documentation on storage options
- Multi-Stage Builds - How to build development and production images from one Dockerfile
- Developing inside a Container - VS Code DevContainers guide
- Best practices for writing Dockerfiles - Production Dockerfile optimizations
- Docker Compose for Development - Multi-container development workflows