Your dev Dockerfile is lying to you

August 27, 2026

A fast and reliable feedback loop surprisingly doesn't start with automated tests. It starts with the runtime environment. We all know the famous words: "Works on my machine!" If this happens when deploying something to development vs. developing things locally, so be it! Not the best thing, but no large harm is done that way. It's something completely different when things are working on development and not on production. That can be a huge problem. So what can we do to prevent that?

Scalable FastAPI Applications on AWS

Build scalable applications with FastAPI and Terraform that run on AWS!

Take Course

Development vs. Production Docker image

One anti-pattern that I quite often see is two different Dockerfiles. One for development/staging and one for production. The production one is usually the "optimized" one. Sooner or later, there's a tiny difference that causes something not to work. For example, a different user is used, and inside the production Docker image the app can't write to a directory it needs at runtime. Or we update the Python image version only for the production Dockerfile. The larger the difference, the more likely the troubles.

For example, a development Dockerfile:

# Dockerfile.dev
FROM python:3.13

WORKDIR /app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .

CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

And a separate production Dockerfile:

# Dockerfile.prod
FROM python:3.14-slim

RUN groupadd -r app && useradd -r -g app app

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN python manage.py collectstatic --noinput

USER app

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

Can you spot the differences? There are a few: 1. Production serves traffic with gunicorn while development uses runserver. That's not an optimization difference - it's a different server, single-threaded, with an auto-reloader attached. Whatever development tells you about concurrency, timeouts, or performance is worthless. 2. Production uses Python 3.14 while development still uses 3.13 - someone updated production but forgot about development. 3. Production uses a non-root app user. Everything is copied and generated as root, so at runtime app can't write anywhere under /app - logs, uploads, or any cache directory the app creates. Development runs as root and never hits it. 4. Production uses a slim base image which may be missing system libraries that the full image includes.

Development is no different from production

If we want our feedback loop to be reliable, we need to make sure that environments are as similar to each other as possible. That applies to the Docker image as well. You should have one Dockerfile which is as optimized as possible - multi-stage build, slim base images, correct command ordering, and so on. It should be used to build the Docker image for every environment. This way, you make sure that what works on development works on production as well - at least regarding the Docker image. That helps you increase trust in your feedback loop. If end-to-end tests are passing on development, you can be sure the Docker image won't be the reason things break on production. Configuration, secrets, and scale still differ - but that's one whole class of surprises gone.

You can learn about Docker best practices for Python Docker images in Docker Best Practices for Python Developers.

Not only that. Using an optimized Docker image in every environment also speeds up deployments as there's less data to download. That's what makes our feedback loop faster.

Here's what that looks like - a single optimized Dockerfile for all environments:

FROM python:3.14-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.14-slim

RUN groupadd -r app && useradd -r -g app app

WORKDIR /app

COPY --from=builder /install /usr/local
COPY --chown=app:app . .

USER app

RUN python manage.py collectstatic --noinput

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

The build tooling stays in the builder stage and never ships. collectstatic runs after USER app, so the files it generates are owned by the user that has to read and write them - no chown needed.

The same image is built once, tested on development, and promoted to production. No surprises.

Become a better engineer, one article at a time.

Practices, mindsets, and habits that actually move the needle. Delivered weekly to your inbox.

Conclusion

Keeping environments as similar to each other as possible is essential to building trust in your feedback loop. We should start by using the same Docker image for all environments. This way, we eliminate potential surprises coming from subtle nuances between different Docker images.

Happy engineering!

Share