⌂ Home
Gemini CLI: From Zero to Production
Track 3 — AI SDLC
Module 10 of 18 ~55 min Intermediate
Module 09 · Track 3 — AI SDLC

Deployment & Maintenance

TaskFlow runs perfectly on your machine. Now you need to containerize it, build a CI pipeline that catches regressions, generate a runbook for the operations team, and diagnose production errors without touching the live server. Gemini CLI generates all four — in one focused session, from the same files your code already lives in.

What You'll Learn

  • Improve TaskFlow's single-stage Dockerfile into a multi-stage build with non-root user and health check
  • Generate a docker-compose.yml with SQLite volume persistence and .env wiring
  • Create a complete GitHub Actions workflow: install → test → coverage gate → Docker build
  • Generate a cloudbuild.yaml that deploys to Cloud Run on green tests
  • Produce a structured operations runbook from TaskFlow's source code alone
  • Paste a real-looking SQLAlchemy error log and get Gemini's root-cause analysis
  • Complete the lab: build, run, and verify the full CI pipeline locally

From Code to Container

Analogy — The Moving Company That Packs Your House

Before: You've spent months arranging your apartment perfectly. Every tool is in the right place. Your workflow is smooth. Then you try to describe it to a moving company: "The socket wrench is in the third drawer, the environment variable is..." and you realize you've never written any of it down.

Pain: The Dockerfile, CI pipeline, docker-compose, and runbook are the "written-down" version of your working environment. Writing them from scratch is tedious, error-prone, and often done by different people who weren't there when the code was written.

Mapping: Gemini CLI reads your actual source code — the requirements.txt that lists your dependencies, the database.py that shows your SQLite setup, the auth.py that reveals your environment variable names — and generates deployment artifacts that match. It's the moving company that packs your house by reading it, not by guessing.

TaskFlow already has a Dockerfile — but it's a single-stage build that copies everything, runs as root, and has no health check. Let's fix all three with one prompt.

Why It Matters

Running containers as root is a critical security vulnerability: if an attacker achieves remote code execution inside the container, they get root access to the host filesystem via Docker socket. Non-root containers limit the blast radius. Multi-stage builds reduce image size by 60-80% on average (TaskFlow's single-stage image is ~220MB; the multi-stage version is under 80MB). These are not optional improvements — they are the production standard.

Let's start with the Dockerfile — the foundation everything else builds on.

Dockerfile Generation

TaskFlow's existing Dockerfile (8 lines, single-stage, root user) is a starting point. Here's how to upgrade it:

PowerShell / bash — Dockerfile improvement prompt
gemini "@Dockerfile @requirements.txt Improve this Dockerfile with:
1. Multi-stage build: builder stage (install deps) + runtime stage (copy only what's needed)
2. Non-root user named 'appuser' with UID 1001
3. Health check that hits GET /health every 30s
4. Pinned base image with digest (not just :3.12-slim)
5. Explain each addition with a comment in the Dockerfile"
WHAT: We pass both Dockerfile (current state) and requirements.txt (dependencies). Gemini uses requirements.txt to know exactly what gets installed in the builder stage, so it can copy only the installed packages — not the pip cache or build tools — into the runtime stage.

Before and after: single-stage vs multi-stage

BEFORE — single-stage, 220MB
FROM python:3.12-slim

WORKDIR /app

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

COPY . .

EXPOSE 8000

CMD ["uvicorn", "main:app",
     "--host", "0.0.0.0",
     "--port", "8000"]
AFTER — multi-stage, ~78MB
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir \
    --prefix=/install \
    -r requirements.txt

FROM python:3.12-slim AS runtime
# Copy only installed packages
COPY --from=builder /install /usr/local
WORKDIR /app
# Non-root user (principle of least privilege)
RUN adduser --uid 1001 --disabled-password \
    --gecos "" appuser
USER appuser
# Copy application source
COPY --chown=appuser:appuser . .
EXPOSE 8000
# Health check hits the /health endpoint
HEALTHCHECK --interval=30s --timeout=5s \
    --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; \
    urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "main:app",
     "--host", "0.0.0.0", "--port", "8000"]

What Just Happened?

The multi-stage build uses two FROM statements. The builder stage installs all Python packages to /install. The runtime stage starts fresh from the same base image, copies only the installed package files (not pip's download cache, compiler tools, or build artifacts), creates a non-root user, and runs the application as that user. The health check uses Python's built-in urllib so you don't need curl installed in the container.

Multi-stage build — what stays vs what gets discarded

BUILDER STAGE

RUNTIME STAGE (final image)

Container built. Now give it a network, a volume, and environment variables — with docker-compose.

Docker Compose

Docker Compose defines your multi-service setup in a single file. For TaskFlow it's simple — one service — but the volume mount for SQLite persistence and the .env wiring are worth generating correctly rather than guessing at.

PowerShell / bash — docker-compose prompt
gemini "@Dockerfile @main.py @database.py Generate a docker-compose.yml for TaskFlow with:
1. A service named 'api' using the Dockerfile in the current directory
2. A named volume 'taskflow-data' mounted to where database.py stores the SQLite file
3. Environment variables loaded from a .env file
4. PORT mapped to 8000 on the host
5. restart: unless-stopped
6. A depends_on health check so the service only starts when healthy
7. Comments explaining each section"
WHY @database.py: Gemini reads DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./taskflow.db") and knows the SQLite file is at /app/taskflow.db inside the container (because WORKDIR is /app). It mounts the volume there — not at a guessed path.

Generated docker-compose.yml

docker-compose.yml — Gemini-generated
# docker-compose.yml for TaskFlow
# Usage: docker compose up -d
# Requires: .env file with SECRET_KEY and DATABASE_URL

version: "3.9"

services:
  api:
    # ── Build from the Dockerfile in the current directory ──────
    build:
      context: .
      dockerfile: Dockerfile

    # ── Map host port 8000 to container port 8000 ───────────────
    ports:
      - "8000:8000"

    # ── Load environment variables from .env file ────────────────
    # Create .env from .env.example before running compose up
    env_file:
      - .env

    # ── Persist the SQLite database across container restarts ────
    # database.py: sqlite:///./taskflow.db → /app/taskflow.db
    volumes:
      - taskflow-data:/app/taskflow.db

    # ── Restart automatically unless manually stopped ────────────
    restart: unless-stopped

    # ── Health check (matches Dockerfile HEALTHCHECK) ────────────
    healthcheck:
      test: ["CMD", "python", "-c",
             "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

# ── Named volume — data survives container replacement ───────────
volumes:
  taskflow-data:
    driver: local
GOTCHA: The volume mounts to /app/taskflow.db (the file path), not /app/ (a directory). Mounting a named volume to a file path requires Docker to pre-create the file before binding. If the container starts before the file exists, Docker creates a directory at that path instead — breaking SQLite. The fix is to use sqlite:///./data/taskflow.db and mount to /app/data (a directory). Tell Gemini: "Change the database path to use a /data subdirectory so the volume mount is to a directory, not a file."
Generate your .env.example too

Before committing, ask Gemini to generate the .env.example file so teammates know what variables to set: gemini "@auth.py @database.py @main.py List every environment variable this app reads and generate a .env.example with safe placeholder values"

Container and compose are ready. Now automate the test-build-push pipeline so humans never have to do it manually.

GitHub Actions CI

What is a CI pipeline?

Continuous Integration (CI) is the practice of automatically running your test suite every time code is pushed. A CI pipeline is the configuration file that defines what runs (install, test, build), when it runs (on push, on PR), and what counts as failure (tests fail, coverage drops below 80%). GitHub Actions is GitHub's built-in CI system — the pipeline is a YAML file at .github/workflows/ci.yml.

PowerShell / bash — GitHub Actions prompt
gemini "@requirements.txt @Dockerfile Generate a .github/workflows/ci.yml for TaskFlow that:
1. Triggers on push to main and on pull requests
2. Runs on ubuntu-latest
3. Sets up Python 3.12 and caches pip dependencies
4. Installs dependencies from requirements.txt
5. Runs pytest with --cov and fails the workflow if coverage is below 80%
6. Builds the Docker image (but does not push — no registry credentials needed)
7. Uses a SECRET_KEY secret from GitHub Secrets for the test run
8. Annotates each step clearly"
WHY 'does not push': A push step requires registry credentials. Separating build from push means the CI workflow can run safely on forks and PRs without exposing secrets. Add a separate deploy workflow that only runs on main.

Generated GitHub Actions workflow

.github/workflows/ci.yml — Gemini-generated
name: TaskFlow CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test-and-build:
    runs-on: ubuntu-latest

    steps:
      # ── CHUNK 1: Checkout and Python setup ──────────────────────
      # WHAT: Gets the code and sets up Python with pip caching.
      # WHY:  Caching pip packages saves ~45s per run for TaskFlow's
      #       dependency set.
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python 3.12
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      # ── CHUNK 2: Install dependencies ────────────────────────────
      # WHAT: Installs all packages from requirements.txt plus pytest-cov.
      # WHY:  pytest-cov is not in requirements.txt (it's a dev dependency)
      #       so we install it separately.
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install pytest-cov

      # ── CHUNK 3: Run tests with coverage gate ────────────────────
      # WHAT: Runs pytest, generates a coverage report, and fails if
      #       overall coverage is below 80%.
      # WHY:  --cov-fail-under=80 makes the CI step exit non-zero
      #       (red) if coverage drops. This prevents coverage regressions
      #       from silently merging.
      # GOTCHA: We set SECRET_KEY from GitHub Secrets so auth.py does
      #         not fall back to the insecure dev default.
      - name: Run tests with coverage
        env:
          SECRET_KEY: ${{ secrets.SECRET_KEY }}
        working-directory: ./sample-project/taskflow
        run: |
          pytest tests/ \
            --cov=. \
            --cov-report=term-missing \
            --cov-omit="tests/*" \
            --cov-fail-under=80 \
            -v

      # ── CHUNK 4: Build Docker image ───────────────────────────────
      # WHAT: Builds (but does not push) the Docker image.
      # WHY:  Verifies the Dockerfile is valid and the build succeeds
      #       without requiring any registry credentials.
      - name: Build Docker image
        working-directory: ./sample-project/taskflow
        run: |
          docker build -t taskflow:${{ github.sha }} .
          echo "Image built: taskflow:${{ github.sha }}"
WHY github.sha as tag: Tagging with the commit SHA creates a unique, traceable image for every build. If a deployment causes a regression, you can roll back to the exact image that was built from a specific commit, not just "the one from Tuesday."
GitHub Actions pipeline — step by step

What Just Happened?

Gemini read requirements.txt to know Python 3.12 is the target (from fastapi==0.111.0 which requires Python 3.10+) and Dockerfile to match the build step. It generated a workflow with the correct working-directory for a monorepo layout, a proper coverage gate, and a github.sha image tag — patterns that take experience to get right from scratch.

GitHub Actions handles your open-source flow. For GCP deployments, Cloud Build is the native pipeline.

Google Cloud Build

What is Cloud Build?

Cloud Build is Google Cloud's managed CI/CD service. It runs build steps defined in a cloudbuild.yaml file, uses substitution variables (like $PROJECT_ID) that are automatically set by GCP, pushes images to Artifact Registry (GCP's container registry), and can trigger Cloud Run deployments. Unlike GitHub Actions, it runs inside your GCP project — no credentials needed for pushing to Artifact Registry.

PowerShell / bash — Cloud Build prompt
gemini "@Dockerfile @requirements.txt Generate a cloudbuild.yaml for TaskFlow that:
1. Runs pytest (fail if tests fail — do NOT deploy broken code)
2. Builds the Docker image
3. Pushes to Artifact Registry at us-central1-docker.pkg.dev/$PROJECT_ID/taskflow/api
4. Deploys to Cloud Run service named 'taskflow-api' in us-central1 ONLY if tests pass
5. Uses $COMMIT_SHA as the image tag
6. Sets SECRET_KEY from Secret Manager (not hardcoded)
7. Annotates each step"

Generated cloudbuild.yaml

cloudbuild.yaml — Gemini-generated
# cloudbuild.yaml for TaskFlow
# Trigger: push to main branch via Cloud Build trigger
# Required IAM: Cloud Run Admin, Artifact Registry Writer, Secret Manager Accessor

steps:
  # ── STEP 1: Run tests ─────────────────────────────────────────
  # WHAT: Runs pytest in the Python 3.12 container.
  # WHY:  If tests fail, Cloud Build stops here and does NOT build
  #       or deploy — broken code never reaches production.
  - name: 'python:3.12-slim'
    id: 'run-tests'
    dir: 'sample-project/taskflow'
    entrypoint: bash
    args:
      - '-c'
      - |
        pip install -r requirements.txt pytest pytest-cov -q
        pytest tests/ --cov=. --cov-fail-under=80 -q
    secretEnv: ['SECRET_KEY']

  # ── STEP 2: Build Docker image ────────────────────────────────
  # WHAT: Builds the image and tags it with the commit SHA.
  # WHY:  $COMMIT_SHA is automatically set by Cloud Build — each
  #       image is uniquely traceable to its source commit.
  - name: 'gcr.io/cloud-builders/docker'
    id: 'build-image'
    dir: 'sample-project/taskflow'
    args:
      - 'build'
      - '-t'
      - 'us-central1-docker.pkg.dev/$PROJECT_ID/taskflow/api:$COMMIT_SHA'
      - '-t'
      - 'us-central1-docker.pkg.dev/$PROJECT_ID/taskflow/api:latest'
      - '.'

  # ── STEP 3: Push to Artifact Registry ────────────────────────
  # WHAT: Pushes both the SHA-tagged and latest-tagged images.
  # WHY:  The Cloud Run deploy step references the SHA-tagged image
  #       for precision; the latest tag is for manual pulls.
  - name: 'gcr.io/cloud-builders/docker'
    id: 'push-image'
    args:
      - 'push'
      - '--all-tags'
      - 'us-central1-docker.pkg.dev/$PROJECT_ID/taskflow/api'

  # ── STEP 4: Deploy to Cloud Run ──────────────────────────────
  # WHAT: Updates the Cloud Run service to use the new image.
  # WHY:  --no-traffic means the new revision is deployed but
  #       does not receive traffic until you manually promote it.
  #       Remove --no-traffic for fully automated blue/green.
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    id: 'deploy-cloud-run'
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - 'taskflow-api'
      - '--image=us-central1-docker.pkg.dev/$PROJECT_ID/taskflow/api:$COMMIT_SHA'
      - '--region=us-central1'
      - '--platform=managed'
      - '--allow-unauthenticated'
      - '--set-secrets=SECRET_KEY=taskflow-secret-key:latest'

# ── Secret Manager integration ───────────────────────────────────
# Pulls SECRET_KEY from Secret Manager at build time.
# Requires the Cloud Build service account to have the
# Secret Manager Secret Accessor role.
availableSecrets:
  secretManager:
    - versionName: projects/$PROJECT_ID/secrets/taskflow-secret-key/versions/latest
      env: 'SECRET_KEY'

images:
  - 'us-central1-docker.pkg.dev/$PROJECT_ID/taskflow/api:$COMMIT_SHA'
  - 'us-central1-docker.pkg.dev/$PROJECT_ID/taskflow/api:latest'

options:
  logging: CLOUD_LOGGING_ONLY
GOTCHA: The availableSecrets block makes SECRET_KEY available to steps via secretEnv — it is never written to the build log. If you instead pass it as a plain env variable, it will appear in the Cloud Build logs in plaintext. Always use secretEnv + availableSecrets for sensitive values.
Pipeline is automated. Now document what to do when the automation breaks — the runbook.

Runbook Generation

What is a runbook?

A runbook (also called a "playbook" or "operations manual") is a document that tells an on-call engineer exactly what to do in a given situation — how to start the service, how to restart it after a crash, how to roll back a bad deployment, and what the most common errors look like with their fixes. The goal is that a person who has never seen the application before can follow the runbook and resolve a production incident. Good runbooks are concrete, numbered, and include expected outputs so the engineer knows when each step succeeded.

PowerShell / bash — runbook prompt
gemini "@main.py @database.py @Dockerfile @auth.py Write an operations runbook for TaskFlow covering:
1. Service startup — local and Docker
2. Health check verification
3. Database reset procedure (for development)
4. Graceful shutdown
5. Common errors and their fixes:
   - 'detail: Could not validate credentials' in response body
   - Database lock errors
   - 422 Unprocessable Entity from the tasks endpoint
6. Format as numbered steps with expected outputs"
WHAT: By referencing @auth.py, Gemini knows what "Could not validate credentials" actually means in the codebase (the credentials_exception in get_current_user) and gives a specific diagnosis rather than a generic one.

Generated runbook (excerpt)

RUNBOOK.md — Gemini-generated (excerpt)
# TaskFlow Operations Runbook

## 1. Service Startup

### Local (development)
1. Navigate to the project directory: `cd sample-project/taskflow`
2. Ensure .env exists: `cp .env.example .env`
3. Install dependencies: `pip install -r requirements.txt`
4. Start the server: `uvicorn main:app --reload`
5. Expected output:
   ```
   INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
   INFO:     Started reloader process [PID]
   ```
6. Verify: `curl http://localhost:8000/health`
   Expected: `{"status": "ok", "service": "taskflow"}`

### Docker
1. Build image: `docker build -t taskflow:local .`
2. Run container: `docker run -p 8000:8000 --env-file .env taskflow:local`
3. Verify health: `docker inspect --format='{{.State.Health.Status}}' `
   Expected: `healthy`

---

## 2. Health Check Verification

The `/health` endpoint returns HTTP 200 with `{"status": "ok", "service": "taskflow"}`.

- If it returns 200: Service is running normally.
- If it returns 503 or times out: Check logs with `docker logs  --tail 50`.
- If the endpoint does not exist: The container is running but the app failed to start.
  Check for import errors: `docker logs  2>&1 | grep "Error"`

---

## 5. Common Errors and Fixes

### "Could not validate credentials" (HTTP 401)
**What it means:** `auth.py get_current_user()` raised `credentials_exception`.
This happens when:
- No Authorization header is present
- The token has expired (default: 30 minutes, set by ACCESS_TOKEN_EXPIRE_MINUTES)
- The token was signed with a different SECRET_KEY than the current one
- The user account was deleted after the token was issued

**Fix:** Re-authenticate via POST /users/login. If the issue is SECRET_KEY mismatch
after a redeploy, all existing tokens are invalidated — users must log in again.

### Database lock error
**What it means:** SQLite is single-writer. If multiple requests try to write
simultaneously, one will get `sqlite3.OperationalError: database is locked`.
**Fix (development):** Restart the server. SQLite auto-releases locks.
**Fix (production):** This indicates high concurrent write load. Either:
  1. Add a connection pool timeout in database.py: `connect_args={"timeout": 30}`
  2. Migrate to PostgreSQL for production workloads above 50 concurrent users.

### 422 Unprocessable Entity on POST /tasks/
**What it means:** Pydantic validation failed. The request body is missing a
required field or has the wrong type.
**Diagnosis:** The response body includes a `detail` array specifying which field
failed. Example: `{"detail": [{"loc": ["body", "title"], "msg": "field required"}]}`
**Fix:** Check the request body against the TaskCreate schema in schemas.py.
Pro tip: version your runbook with your code

Commit RUNBOOK.md to the same repository as your application. When the code changes (new environment variables, new error messages, new endpoints), regenerate the relevant sections with Gemini: gemini "@auth.py @RUNBOOK.md Section 5 (Common Errors) is outdated. Update it based on the current auth.py implementation."

The runbook tells you what to do. Log analysis shows you what happened. Let's diagnose a real-looking production error.

Log Analysis

Production errors are often buried in stack traces that look intimidating but have a simple root cause. Gemini can read the stack trace, cross-reference your source code, and tell you exactly where the problem is and how to fix it.

Diagnosing a SQLAlchemy connection pool error

Paste this error log into Gemini (or use a real log from your deployment):

Production error log — TaskFlow
ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File ".../sqlalchemy/pool/base.py", line 686, in checkout
    rec = self._do_get()
  File ".../sqlalchemy/pool/impl.py", line 145, in _do_get
    self._dec_overflow()
  File ".../sqlalchemy/util/langhelpers.py", line 70, in __exit__
    compat.raise_(exc_value, with_traceback=exc_tb)
  File ".../sqlalchemy/pool/impl.py", line 142, in _do_get
    raise exc.TimeoutError(...)
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10
is reached, connection timed out, timeout 30

During handling of the above exception, another exception occurred:
  ...
  File ".../taskflow/database.py", line 22, in get_db
    db = SessionLocal()
fastapi.exceptions.ResponseValidationError: 1 validation error ...
PowerShell / bash — log diagnosis prompt
gemini "TaskFlow returned this error in production:

[paste the error log above]

Look at @database.py and @models.py and identify:
1. The root cause of this specific error
2. Whether the current database.py configuration would cause this
3. The immediate fix and the long-term fix"

Gemini's diagnosis:

Gemini response — root cause analysis
Root cause analysis:

The error is QueuePool limit exceeded. SQLAlchemy's connection pool has
hit its maximum size (default: 5 connections + 10 overflow = 15 total).
New requests cannot acquire a database connection within 30 seconds.

Why this is happening in TaskFlow:

Looking at database.py, the SessionLocal is created with default pool
settings:
  SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

And the engine uses SQLite's default pool (StaticPool for SQLite in-memory,
or the default NullPool — but your code uses a file-based SQLite with the
default QueuePool). The real issue is likely in get_db():

  def get_db():
      db = SessionLocal()
      try:
          yield db
      finally:
          db.close()

If an exception occurs in a route handler BEFORE the finally block executes
(e.g., in a Pydantic validation step), the session may not be closed, leaking
connections back into the pool.

IMMEDIATE FIX:
Add pool_pre_ping=True to the engine to automatically discard stale connections:

  engine = create_engine(
      DATABASE_URL,
      connect_args={"check_same_thread": False},
      pool_pre_ping=True,  # ← add this
  )

Also add a connection timeout to give queued requests more time:
  engine = create_engine(
      DATABASE_URL,
      connect_args={"check_same_thread": False},
      pool_timeout=30,
      pool_recycle=300,  # recycle connections every 5 minutes
  )

LONG-TERM FIX:
SQLite is not designed for more than ~10 concurrent write connections.
At this traffic level, migrate to PostgreSQL. Change DATABASE_URL to:
  postgresql+psycopg2://user:password@host/taskflow

and remove the check_same_thread connect_arg (SQLite-specific).
WHY this works: Gemini read database.py and found the exact lines creating the engine and session. It correlated the stack trace frame (database.py line 22) with the source code to identify the session leak pattern. The diagnosis is specific to TaskFlow, not a generic "SQLAlchemy pool error" article.
SQLAlchemy connection pool — normal vs exhausted
You have all the pieces. Now build the complete pipeline end-to-end in the lab.

Lab: Full Deployment Pipeline

This lab builds the complete production pipeline for TaskFlow: improved Dockerfile → docker-compose → GitHub Actions CI → local test with act. Expected time: 40–50 minutes.

Prerequisites

Docker Desktop installed and running. For Step 6, install act (run GitHub Actions locally): winget install nektos.act on Windows or brew install act on Mac.

  1. Improve the Dockerfile:
    PowerShell / bash
    cd sample-project/taskflow
    gemini "@Dockerfile @requirements.txt Improve with multi-stage build,
    non-root user, health check. Output only the Dockerfile content."

    Save Gemini's output as the new Dockerfile.

  2. Build and test the improved image:
    PowerShell
    docker build -t taskflow:lab .
    docker run -d -p 8000:8000 -e SECRET_KEY=lab-secret taskflow:lab
    Start-Sleep -Seconds 5
    Invoke-RestMethod http://localhost:8000/health

    Expected: {"status": "ok", "service": "taskflow"}

  3. Generate docker-compose.yml:
    PowerShell / bash
    gemini "@Dockerfile @database.py @main.py Generate docker-compose.yml
    with SQLite volume, .env file, restart policy, and health check."
  4. Create .env and test compose:
    PowerShell
    # Create .env (do NOT commit this file)
    "SECRET_KEY=compose-test-secret`nDATABASE_URL=sqlite:///./taskflow.db" | Out-File -FilePath .env -Encoding utf8
    docker compose up -d
    Start-Sleep -Seconds 8
    docker compose ps

    Expected: service api with status healthy.

  5. Generate GitHub Actions workflow:
    PowerShell / bash
    gemini "@requirements.txt @Dockerfile Generate .github/workflows/ci.yml
    with Python 3.12, pytest --cov --cov-fail-under=80, Docker build step."
    PowerShell — save to correct path
    # From the project root
    New-Item -ItemType Directory -Force -Path .github/workflows
    # Save Gemini's output as .github/workflows/ci.yml
  6. Run the CI pipeline locally with act:
    PowerShell
    # From the repository root
    act push --secret SECRET_KEY=test-secret -P ubuntu-latest=catthehacker/ubuntu:act-latest

    Expected terminal output:

    Expected act output
    [TaskFlow CI/test-and-build]  Checkout repository
    [TaskFlow CI/test-and-build]  Set up Python 3.12
    [TaskFlow CI/test-and-build]  Install dependencies
    [TaskFlow CI/test-and-build]  Run tests with coverage
      ===== 28 passed in 3.41s =====
      TOTAL  146  8  95%
    [TaskFlow CI/test-and-build]  Build Docker image
      Successfully built abc123def456
    [TaskFlow CI/test-and-build]  Job succeeded
  7. Clean up:
    PowerShell
    docker compose down
    docker image rm taskflow:lab taskflow:local 2>$null
What You've Built

A complete production deployment pipeline: a hardened multi-stage Docker image (~78MB, non-root, health check), a docker-compose setup with persistent storage and environment isolation, and a CI workflow that gates deployment on test coverage. Every artifact was generated from TaskFlow's actual source files — not from a template you adapted.

Knowledge Check

Five questions anchored to TaskFlow's actual files and the concepts in this module.

1. In the multi-stage Dockerfile for TaskFlow, why does the runtime stage use COPY --from=builder /install /usr/local instead of running pip install again?

A
Running pip install in the runtime stage would fail because pip is not installed in the slim base image
B
Copying only the installed packages excludes pip's download cache, wheel build artifacts, and compiler tools — reducing the final image size by 60-80%
C
The runtime stage runs as a non-root user and pip requires root to install packages
D
COPY is faster than running pip install

2. The generated docker-compose.yml mounts the taskflow-data volume to /app/taskflow.db. How did Gemini know this was the right path?

A
It's the default path for all FastAPI applications
B
Gemini read database.py and found sqlite:///./taskflow.db — combined with WORKDIR /app in the Dockerfile, this resolves to /app/taskflow.db
C
Gemini guessed based on the project name "taskflow"
D
SQLite always stores files in the working directory by convention

3. The GitHub Actions workflow uses --cov-fail-under=80. What happens if you merge a PR that drops coverage from 82% to 78%?

A
The Docker build step fails because it depends on the test step
B
The "Run tests with coverage" step exits with a non-zero code, marking the entire CI job as failed — the Docker build step does not run
C
GitHub Actions shows a warning but the PR can still be merged
D
The coverage report is posted as a PR comment but the job still passes

4. In the cloudbuild.yaml, the SECRET_KEY is passed using availableSecrets + secretEnv rather than a plain env variable. Why does this matter?

A
Cloud Build does not support plain env for substitution variables
B
Plain env values are written to the Cloud Build log in plaintext; secretEnv values are redacted — preventing the secret from appearing in your build history
C
Secret Manager secrets can only be accessed through secretEnv, not env
D
The availableSecrets block provides automatic secret rotation

5. The log analysis showed a QueuePool limit exceeded error in TaskFlow. Gemini diagnosed the root cause by reading database.py. What specific pattern in that file does it identify as the trigger?

A
The engine is created without a pool_size argument
B
The check_same_thread=False argument causes connection leaks
C
If an exception occurs in a route handler before the finally block in get_db(), the session is not closed — leaking connections back into the pool until it fills up
D
SQLite does not support connection pools and should use NullPool