Free lesson · Forward Deployed GenAI Engineering

Package prototypes with Dockerfiles, Helm charts, and K8s manifests

You build a PrototypePackager that produces multi-stage Dockerfiles, parameterized Helm charts, and K8s manifests for consistent demo deployments.

Course: AI Solution Delivery · Chapter 4 · Rapid AI Prototyping

Free to read — no subscription required.

Introduction

When you build an AI prototype that works on your laptop, the real challenge is making it portable enough to share, demo, and deploy across environments without manual configuration drift. Sprint teams often lose hours rebuilding broken dependency chains or debugging environment mismatches that never appeared locally. This lesson teaches you how to generate production-ready Dockerfiles and Helm chart manifests directly from validated Python configuration objects — turning a working prototype into a deployable artifact in minutes, not days. By the end, you'll be able to package any RAG pipeline, agent workflow, or multi-provider service into consistent container images and Kubernetes-ready charts.

Key Terminology

  • Environment drift — The divergence between the configuration a prototype runs with locally and what it encounters in shared or production environments; this lesson eliminates drift by encoding all deployment decisions in a validated DockerConfig object that travels with the code.
  • DockerConfig — A Pydantic BaseModel that validates and centralizes every container packaging parameter — base_image, requirements_file, app_entrypoint, exposed_port, env_vars, and system_packages — into a single typed Python object that PrototypePackager consumes to emit a Dockerfile.
  • PrototypePackager — A Python class whose generate_dockerfile method accepts a DockerConfig instance and returns a complete multi-stage Dockerfile string, replacing hand-authored Dockerfiles with a programmatic, reusable artifact generator.
  • Multi-stage Dockerfile — A Dockerfile that uses two FROM blocks: a builder stage that installs Python dependencies into an isolated prefix directory, and a runtime stage that copies only those packages into a clean image — reducing a FastAPI prototype from over 1.2 GB to under 350 MB.
  • Prefix installation — The pip install --no-cache-dir --prefix=/install technique that installs packages into a self-contained directory tree so the runtime stage can copy them cleanly with COPY --from=builder /install /usr/local, leaving pip caches and build artifacts confined to the discarded builder layer.

Concepts

Loading diagram...

Why Prototypes Break Across Environments

A prototype that runs perfectly on a developer's laptop often fails in a shared staging environment because the two environments differ in subtle ways: a different Python patch version, a missing system library, an undocumented environment variable, or a dependency installed globally by a previous project. These mismatches compound across sprint cycles as team members clone the repo, add packages, and iterate on configuration. The result is hours of debugging that has nothing to do with the AI logic being built.

The root cause is not carelessness — it's that environment knowledge is implicit. It lives in a developer's shell profile, a handwritten Dockerfile that hasn't been updated since week one, or a README step that was accurate six sprints ago. The fix is to make that knowledge explicit and validated: capture every packaging decision in a typed Python object that the system can verify before any build happens.

Configuration as Code: The DockerConfig Contract

DockerConfig is a Pydantic BaseModel that turns informal packaging decisions into a verifiable contract. Each field covers one dimension of the deployment environment: base_image pins the Python runtime, system_packages declares OS-level libraries like libpq-dev, env_vars encodes runtime configuration, and app_entrypoint with exposed_port align the Uvicorn process server with the network binding.

Because DockerConfig is a Pydantic model, invalid values are rejected at object creation time — before a single line of Dockerfile is emitted. The same config schema also applies across every service type in a sprint: a RAG pipeline, an agent workflow server, or a multi-provider endpoint each get their own DockerConfig instance while sharing the same generation logic in PrototypePackager (see Code Walkthrough). Per-service variation lives in the config; packaging logic is defined once.

The Multi-Stage Build Contract

A single-stage Dockerfile installs dependencies into the same layer that runs the application, which means pip's download caches, wheel build artifacts, and compiler tooling all end up in the final image — bloating a straightforward FastAPI prototype past 1.2 GB. Multi-stage builds solve this by treating build and runtime as separate concerns with a hard boundary between them.

The builder stage installs packages into a controlled directory using pip install --prefix=/install. The runtime stage starts from the same clean base image and copies only that directory into /usr/local, where Python's import system will find it. Every build-time artifact stays in the discarded builder layer and never reaches the image that ships. PrototypePackager generates this two-stage structure automatically from the DockerConfig fields — no manual Dockerfile authoring required, and no risk of accidentally shipping a build-only tool into production. The output is a reproducible image under 350 MB that any team member can verify immediately with docker build and docker run.

Code Walkthrough

Now that you understand how DockerConfig and PrototypePackager form the core packaging abstractions, let's walk through what they produce in practice.

The PrototypePackager.generate_dockerfile method accepts a validated DockerConfig instance and emits a multi-stage Dockerfile string. The build stage installs Python dependencies into an isolated prefix directory; the runtime stage copies only those installed packages into a clean image, discarding pip caches and build tooling. A FastAPI prototype built this way typically shrinks from over 1.2 GB (single-stage) to under 350 MB.

Code snippetpython
1from pydantic import BaseModel 2from typing import List, Dict 3 4class DockerConfig(BaseModel): 5 base_image: str = "python:3.11-slim" 6 requirements_file: str = "requirements.txt" 7 app_entrypoint: str = "main:app" 8 exposed_port: int = 8000 9 env_vars: Dict[str, str] = {} 10 system_packages: List[str] = [] 11 12class PrototypePackager: 13 def generate_dockerfile(self, config: DockerConfig) -> str: 14 dockerfile = f"""# Build stage 15FROM {config.base_image} AS builder 16WORKDIR /build 17COPY {config.requirements_file} . 18RUN pip install --no-cache-dir --prefix=/install -r {config.requirements_file} 19 20# Runtime stage 21FROM {config.base_image} 22WORKDIR /app 23COPY --from=builder /install /usr/local 24COPY . . 25""" 26 if config.system_packages: 27 pkgs = " ".join(config.system_packages) 28 dockerfile += f"\nRUN apt-get update && apt-get install -y {pkgs} && rm -rf /var/lib/apt/lists/*\n" 29 for key, value in config.env_vars.items(): 30 dockerfile += f"ENV {key}={value}\n" 31 dockerfile += f'\nEXPOSE {config.exposed_port}\n' 32 dockerfile += f'CMD ["uvicorn", "{config.app_entrypoint}", "--host", "0.0.0.0", "--port", "{config.exposed_port}"]\n' 33 return dockerfile 34 35# Package a RAG prototype 36packager = PrototypePackager() 37config = DockerConfig( 38 app_entrypoint="api:app", 39 exposed_port=8080, 40 env_vars={"VECTOR_DB_URL": "http://chroma:8000"}, 41 system_packages=["libpq-dev"], 42) 43print(packager.generate_dockerfile(config))

The separation between build and runtime stages is what keeps prototypes shippable. Dependencies, caches, and compiler artifacts accumulate in the builder layer and never reach the final image. Each field in DockerConfig maps directly to a Dockerfile directive: base_image governs both stages, env_vars emits ENV lines, system_packages becomes an apt-get install command, and exposed_port aligns the EXPOSE instruction with the Uvicorn binding.

For teams iterating across sprint cycles, the same DockerConfig schema can be reused across RAG services, agent workflow servers, and multi-provider pipeline endpoints — the packager ensures each one deploys identically without manual Dockerfile maintenance between sprints.

Verify by running docker build -t my-prototype . against the generated Dockerfile and confirming the image builds without errors and the container starts cleanly with docker run -p 8080:8080 my-prototype.

Do's and Don'ts

Having walked through the material above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do use multi-stage Dockerfile generation via PrototypePackager.generate_dockerfile — the build stage installs dependencies into an isolated --prefix=/install directory so only the final packages reach the runtime image, cutting a typical FastAPI prototype from over 1.2 GB to under 350 MB without manual layer tuning.
  2. Do declare all environment-specific values — env_vars, system_packages, and exposed_port — in the DockerConfig Pydantic model before generating the Dockerfile — each field maps directly to a Dockerfile directive, so centralizing config there eliminates manual edits to Dockerfiles between sprint cycles and prevents environment drift across RAG, agent, and multi-provider deployments.
  3. Do verify the generated Dockerfile end-to-end with docker build -t my-prototype . and docker run -p 8080:8080 my-prototype — a successful build confirms pip resolution is clean inside the build stage, and a clean container start confirms the CMD uvicorn binding matches exposed_port, catching alignment failures before the image is shared or deployed.

Don'ts

  1. Don't copy the full build stage into the runtime image by using a single-stage Dockerfile — without the COPY --from=builder /install /usr/local pattern, pip caches, build tooling, and compiler artifacts bloat the final image and expose build-time attack surface in every deployed container.
  2. Don't hardcode environment-specific values like VECTOR_DB_URL or system package lists directly inside the Dockerfile string — bypassing DockerConfig.env_vars and system_packages means the packager no longer controls those directives, and sprint teams end up with per-service Dockerfile forks that diverge silently across RAG pipelines, agent workflow servers, and multi-provider endpoints.
  3. Don't assume the app_entrypoint and exposed_port fields are independent — the CMD uvicorn line bakes app_entrypoint (e.g., "api:app") and exposed_port together; setting exposed_port=8080 in DockerConfig but starting Uvicorn on a different port in application code makes the EXPOSE instruction a lie and causes docker run -p mappings to silently fail.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the Forward Deployed GenAI Engineering subscription.

From · cancel anytime

More free lessons in AI Solution Delivery

All free lessons in Forward Deployed GenAI Engineering