Stop Rebuilding Docker Images on Every Code Change

Waiting 2 minutes to see a typo fix? Runtime containers separate your code from your runtime, enabling instant feedback without sacrificing containerized consistency. Mount your code instead of baking it in. Edit files, see changes instantly. No rebuilds, no waiting.

WORDS: 2562 | CODE BLOCKS: 36 | EXT. LINKS: 7

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:

dockerfile Dockerfile

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:

  1. Rebuild the image (docker build -t myapp .)
  2. Stop the old container
  3. Start a new container from the new image
  4. 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:

bash

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.

Important
Runtime containers solve this properly. Build an image with only the runtime and dependencies. Mount your code at runtime. Separate concerns cleanly.

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 runtime

Created 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):

dockerfile Dockerfile

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:

dockerfile Dockerfile.runtime

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:

bash

docker build -f Dockerfile.runtime -t myapp-runtime .

Run it with your code mounted:

bash

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.

dockerfile Dockerfile.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:

bash

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.

yaml docker compose.yml

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:

bash

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:

yaml

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:

yaml docker compose.yml

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:

yaml

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.

dockerfile Dockerfile.runtime

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"]
yaml docker compose.yml

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).

dockerfile Dockerfile

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"]
yaml docker compose.yml

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:

dockerfile Dockerfile.runtime

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"]
yaml docker compose.yml

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:

markdown
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

makefile Makefile

.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 development
  • make deps - rebuild and restart after changing dependencies

Solution 3: File watchers

Use a file watcher to automatically rebuild when dependency files change:

bash watch-deps.sh

#!/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:

yaml


# 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.

yaml


# 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:

dockerignore .dockerignore

**/**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.

bash

docker run -u $(id -u):$(id -g) -v $(pwd)/src:/app/src myapp

Or set the user in docker compose.yml:

yaml

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:

bash check-deps.sh

#!/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:

makefile

.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.

yaml .github/workflows/ci.yml


# 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 environment

Never 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.

yaml docker compose.yml

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:

dockerfile

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:

dockerfile

FROM node:18-slim

WORKDIR /app

COPY package\*.json ./
RUN npm ci

CMD ["npm", "start"]

Start everything:

bash

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