Model Deployment II — Docker, GitHub, and the Cloud

Containerize Flask + Streamlit and Deploy the ML Product to Google Cloud Run

Author

MLStation

Part II: make the localhost product portable and public

Part I produced a working XGBoost model, Flask API and Streamlit UI. Now we package each service, reproduce it with Docker, push source to GitHub, and deploy the containers to Google Cloud Run.

← Part I: Data, XGBoost, Flask & Streamlit Part III: Hosting alternatives →

1. Where Part I ended

Part I saved the fitted pipeline at:

artifacts/energy_xgb_pipeline.joblib

and Flask loaded it at startup with:

MODEL = joblib.load(MODEL_PATH)

The Streamlit application did not load the model. It called Flask through HTTP. We preserve that architecture in production.

Browser
   │
   ▼
Streamlit container  ── HTTPS ──> Flask API container
                                      │
                                      ▼
                         energy_xgb_pipeline.joblib

2. Why Docker?

Image vs container. A Docker image is an immutable recipe containing code, Python dependencies and startup instructions. A container is a running instance of that image. “It works on my laptop” becomes less important because development and cloud execute the same image.

We will create two images because frontend and backend scale independently.

3. Backend Dockerfile

The backend image includes the serialized model because Flask must load it when the container starts. Create Dockerfile.backend in the project root.

Dockerfile.backend
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY artifacts/ ./artifacts/
COPY backend/ ./backend/
ENV PORT=8080
CMD exec gunicorn --bind :${PORT} --workers 2 --threads 4 --timeout 120 backend.app:app
What happens at startup? Docker starts Gunicorn → Gunicorn imports backend.app:app → importing backend/app.py executes joblib.load(...) once per worker → the worker is ready to answer requests.

Why Gunicorn instead of app.run()?

Flask’s development server is for development. Gunicorn is a production WSGI process manager: it can run multiple workers, handle signals correctly, and work naturally in container platforms.

4. Frontend Dockerfile

Create Dockerfile.frontend.

Dockerfile.frontend
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY frontend/ ./frontend/
ENV PORT=8080
CMD streamlit run frontend/streamlit_app.py \
    --server.address=0.0.0.0 \
    --server.port=${PORT} \
    --server.headless=true

Notice that the frontend image does not copy artifacts/. It needs only the Flask URL.

5. .dockerignore

.dockerignore
.git
.gitignore
__pycache__/
*.pyc
.venv/
venv/
.ipynb_checkpoints/
data/
artifacts/test_predictions.csv

Do not exclude artifacts/energy_xgb_pipeline.joblib; the backend image needs it.

6. Build and run locally

Action. Build two reproducible images, then run the backend on port 8000 and Streamlit on 8501.
docker build -f Dockerfile.backend -t energy-api:local .
docker build -f Dockerfile.frontend -t energy-ui:local .

Create a network and run both containers:

docker network create energy-net

docker run --rm --name energy-api --network energy-net \
  -p 8000:8080 energy-api:local

In another terminal:

docker run --rm --name energy-ui --network energy-net \
  -p 8501:8080 \
  -e API_BASE_URL=http://energy-api:8080 \
  energy-ui:local

Open http://localhost:8501.

Important networking idea. Inside Docker, localhost means “this same container.” Streamlit reaches Flask through Docker DNS name energy-api, not localhost.

7. Docker Compose: one command for the complete product

Create compose.yaml.

compose.yaml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.backend
    ports:
      - "8000:8080"

  ui:
    build:
      context: .
      dockerfile: Dockerfile.frontend
    ports:
      - "8501:8080"
    environment:
      API_BASE_URL: http://api:8080
    depends_on:
      - api

Run:

docker compose up --build

Stop:

docker compose down

8. GitHub: source control, not the runtime

Three different things. GitHub stores source history. A container registry stores built images. Cloud Run executes containers. Keeping these concepts separate makes cloud architecture much easier to understand.
GitHub repository ──build──> Container image ──store──> Artifact Registry
                                                       │
                                                       ▼
                                                 Cloud Run service

GitHub credentials

For command-line pushes, use either GitHub CLI (gh auth login) or SSH. Do not put a GitHub password/token inside source files.

git add .
git commit -m "Part II: Dockerize API and Streamlit"
git push origin main

9. Choose a production target: Google Cloud Run

Why Cloud Run for this course? It teaches the transferable container model: build an image, deploy a service, inject configuration, receive an HTTPS URL, and let the platform scale instances with traffic. It also avoids teaching students to administer a VM before they understand deployment.

The architecture becomes:

                    Internet
                       │
             ┌─────────▼─────────┐
             │ Streamlit Cloud Run│
             └─────────┬─────────┘
                       │ HTTPS + API_BASE_URL
             ┌─────────▼─────────┐
             │ Flask Cloud Run   │
             │ + XGBoost artifact│
             └───────────────────┘

10. Google Cloud prerequisites and credentials

You need a Google Cloud project with billing enabled and the gcloud CLI installed. Authenticate interactively on your own machine:

gcloud auth login
gcloud auth application-default login
gcloud config set project YOUR_PROJECT_ID
Credentials rule. Never paste downloaded service-account JSON keys into GitHub. For local learning, use interactive gcloud auth login. For automated CI/CD, prefer short-lived identity federation / platform-managed service accounts rather than long-lived keys.

Enable the services used below:

gcloud services enable run.googleapis.com \
  cloudbuild.googleapis.com \
  artifactregistry.googleapis.com

Set shell variables:

export PROJECT_ID="YOUR_PROJECT_ID"
export REGION="us-central1"
export REPOSITORY="energy-ml"

11. Create an Artifact Registry repository

Artifact Registry stores container images.

gcloud artifacts repositories create $REPOSITORY \
  --repository-format=docker \
  --location=$REGION \
  --description="MLStation energy deployment images"

12. Build the backend image in Google Cloud

Cloud Build reads our Dockerfile and pushes the resulting image into Artifact Registry.

gcloud builds submit \
  --tag $REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/energy-api:v1 \
  --file Dockerfile.backend .

Deploy Flask

gcloud run deploy energy-api \
  --image $REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/energy-api:v1 \
  --region $REGION \
  --allow-unauthenticated \
  --port 8080 \
  --memory 1Gi \
  --cpu 1 \
  --min 0 \
  --max 10

Cloud Run prints an HTTPS service URL. Save it:

export API_URL="https://YOUR-ENERGY-API-URL.run.app"
curl "$API_URL/health"
curl "$API_URL/api/v1/meta"
The backend is now public. The cloud container loads the same energy_xgb_pipeline.joblib that you tested locally. No training occurs on Cloud Run startup.

13. Build and deploy Streamlit

Build the frontend image:

gcloud builds submit \
  --tag $REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/energy-ui:v1 \
  --file Dockerfile.frontend .

Deploy it and inject the Flask URL as an environment variable:

gcloud run deploy energy-ui \
  --image $REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/energy-ui:v1 \
  --region $REGION \
  --allow-unauthenticated \
  --port 8080 \
  --set-env-vars API_BASE_URL=$API_URL \
  --memory 1Gi \
  --cpu 1 \
  --min 0 \
  --max 10

Open the HTTPS URL returned for energy-ui.

14. What “scaling” means here

If one container instance can process roughly \mu requests/second and traffic arrives at rate \lambda, utilization is conceptually related to

\rho=\frac{\lambda}{c\mu},

where c is the number of active instances. Real autoscaling is more nuanced, but the intuition is useful: more concurrent demand eventually requires more serving capacity.

Cloud Run can add instances; --max 10 protects against uncontrolled scaling/cost. --min 0 permits scale-to-zero, trading some first-request latency for lower idle cost.

15. Production improvements

For a teaching project, public unauthenticated prediction is convenient. A serious system should additionally consider authentication, request-size limits, schema validation (for example Pydantic), rate limiting, structured logs, monitoring, model registry/versioning, drift monitoring, CI/CD, vulnerability scanning, and private service-to-service authentication.

16. End state

GitHub source → Docker build → Artifact Registry → Flask Cloud Run
GitHub source → Docker build → Artifact Registry → Streamlit Cloud Run
Browser → Streamlit HTTPS → Flask HTTPS → loaded XGBoost pipeline → prediction

← Revisit Part I Part III → Compare hosting alternatives


Series navigation

Part I: Data → EDA → XGBoost → Flask → Streamlit
Part II: Docker → GitHub → Google Cloud Run
Part III: Hosting alternatives → decision framework → practical deployment choices

Back to top