Module 6: Modal — Serverless deployment of LLMs

Your first serverless function with dependencies

In the previous capsule you ran a "hello world" with no dependencies. It worked, but you didn't use anything characteristic of Modal yet: any local Python script does the same.

Now we're going to build a real serverless function: with external dependencies, secret handling, and a clear idea of what happens "underneath" when you call .remote(). It's the mental foundation you need before putting GPUs and LLMs in the following capsules.

By the end you'll be able to:

  • Define a container image with specific dependencies (pip_install, local files, shell commands)
  • Inject secrets (API keys) without writing them in code
  • Observe and understand what exactly the cold start is, and what triggers it
  • Differentiate between .local(), .remote() and .spawn() and when to use each one

Why this capsule matters

Modal isn't "Python in the cloud" in the abstract. Modal is a container that runs your function. If your function needs requests, your container needs to have requests installed. If your function calls OpenAI, your container needs to know the API key.

This is exactly what Docker does, but declared in Python instead of a Dockerfile. Once you have this mental model clear, everything else in the module becomes easy.


The mental model: image + function

Every Modal function lives inside an image. An image is the container's recipe: which base operating system, which dependencies, which files. Modal builds the image once and caches it — following runs reuse the same image and start fast.

┌─────────────────────────────────────┐
│ Container image                     │
│ ┌─────────────────────────────────┐ │
│ │ Linux base (debian slim)        │ │
│ │ + Python 3.11                   │ │
│ │ + pip dependencies              │ │
│ │ + copied files                  │ │
│ │ + environment variables         │ │
│ └─────────────────────────────────┘ │
│                                     │
│ When you call .remote():            │
│ → Modal spins up a container        │
│ → Runs your function inside         │
│ → Returns the result                │
│ → Container stays warm a few minutes│
└─────────────────────────────────────┘

Worked example: weather from a public endpoint

We're going to build a function that queries a public weather API (no API key required) and returns the current temperature of a city. It's deliberately simple — the point is to see the full flow.

Create weather.py:

# weather.py
import modal

# Image with the 'requests' dependency installed
image = modal.Image.debian_slim(python_version="3.11").pip_install("requests")

app = modal.App("weather-demo", image=image)


@app.function()
def current_temperature(city: str) -> dict:
    import requests

    # Open-Meteo is public and requires no API key
    geo = requests.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={"name": city, "count": 1},
        timeout=10,
    ).json()

    if not geo.get("results"):
        return {"error": f"City '{city}' not found"}

    lat = geo["results"][0]["latitude"]
    lon = geo["results"][0]["longitude"]

    weather = requests.get(
        "https://api.open-meteo.com/v1/forecast",
        params={"latitude": lat, "longitude": lon, "current_weather": True},
        timeout=10,
    ).json()

    return {
        "city": city,
        "temperature_c": weather["current_weather"]["temperature"],
        "wind_kmh": weather["current_weather"]["windspeed"],
    }


@app.local_entrypoint()
def main():
    for city in ["Ciudad de México", "Buenos Aires", "Madrid"]:
        print(current_temperature.remote(city))

Run:

modal run weather.py

Expected output (first time):

✓ Initialized.
✓ Building image (this will take ~30s the first time)
  - Installing requests
✓ Created function current_temperature.
{'city': 'Ciudad de México', 'temperature_c': 18.4, 'wind_kmh': 5.1}
{'city': 'Buenos Aires', 'temperature_c': 24.6, 'wind_kmh': 12.3}
{'city': 'Madrid', 'temperature_c': 11.2, 'wind_kmh': 8.7}
✓ App finished.

Second run: much faster (3-5s typical), because the image already exists in the cache and the container is probably still "warm".


What just happened?

Five things, in this order:

  1. Modal serialized your code. Your weather.py and its imports got packaged.
  2. Modal checked whether the image existed. Since it was the first time, it built it (debian slim + pip install requests).
  3. Modal spun up a container with that image.
  4. It ran your function inside the container, once for each .remote("...").
  5. It returned the results to your local process and serialized the response.

The important thing about this flow: the import requests you see in the code runs in the remote container, not on your machine. That's why requests might not be installed locally and it still works. What matters is that it's in the image.


Secret handling

A public API is fine for a demo, but in the real world your function will talk to OpenAI, Anthropic, a DB with a password — something with credentials.

Never put API keys in the source code. Modal has a secrets system that injects them as environment variables into the container.

Create a secret:

modal secret create openai-secret OPENAI_API_KEY=sk-your-real-key

Use it in the function:

import modal

app = modal.App("chat-with-openai")

image = modal.Image.debian_slim().pip_install("openai")

@app.function(
    image=image,
    secrets=[modal.Secret.from_name("openai-secret")],
)
def chat(prompt: str) -> str:
    import os
    from openai import OpenAI

    client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content


@app.local_entrypoint()
def main():
    print(chat.remote("Summarize FastAPI in one sentence"))

os.environ["OPENAI_API_KEY"] reads the variable injected by Modal. Your source code doesn't have the key. If you remove the secret, the function fails with a clear "environment variable not found" error.

Visual verification: open your Modal dashboard → Secrets. You should see openai-secret listed. If you go to the detail, Modal doesn't show you the value (it's a secret): you can only rotate or delete it.


Local vs remote vs spawn

Every decorated function has three ways to be invoked:

CallWhere it runsReturnsWhen to use it
f.local(args)Your machineThe resultQuick debug without paying Modal
f.remote(args)Modal containerThe result (blocking)The normal case
f.spawn(args)Modal containerA handle (non-blocking)Fire-and-forget or massive parallel processing

Example of .spawn for parallelism:

# Launch 100 jobs in parallel
handles = [chat.spawn(f"Summarize {topic}") for topic in topics]
# Collect results when they're ready
results = [h.get() for h in handles]

If you do this with .remote(), the 100 jobs run sequentially and take 100x. With .spawn(), Modal scales to multiple containers and they run in parallel.


Cold start in detail

You call .remote() for the first time. Modal:

  1. Looks for a warm container with your image. If it finds one → it starts almost instantly.
  2. If not, it builds or fetches the image from a distributed cache.
  3. It starts a container from the image (~1-3s).
  4. It imports your Python module inside the container.
  5. It runs the function.

The total time is called the cold start. Its most expensive component is usually #4: importing heavy dependencies (torch, transformers) takes several seconds just to import. For a simple function like current_temperature, the full cold start is ~3-8s. For one that imports transformers it'll be 10-20s. For one that loads the weights of a 14GB model it'll be 30-90s. We'll see it in detail in capsule 06.

Why does it matter? If your HTTP API receives a request and takes 60s to respond because there was a cold start, you lost the user. There are strategies to minimize it, also covered in 06.


Common traps

Trap 1 — "My import fails with ModuleNotFoundError even though it's in requirements.txt." Your requirements.txt isn't applied automatically. Modal only installs what you put in the image. Use image.pip_install_from_requirements("requirements.txt") if you want to reuse it:

image = modal.Image.debian_slim().pip_install_from_requirements("requirements.txt")

Trap 2 — "My function doesn't see my project's files." Modal uploads only the file that runs and the directly imported modules. If you need other files (CSV, JSON, local models), use image.add_local_file() or add_local_dir():

image = modal.Image.debian_slim().add_local_dir("./data", remote_path="/data")

Trap 3 — "I changed the code but it runs the old version." Modal sometimes caches aggressively. If it seems out of date, run with --force or change the App name temporarily to force a rebuild.

Trap 4 — "I hardcoded the API key 'just to test' and committed it." It happens all the time. If you did it, rotate the key immediately (OpenAI/etc dashboard) and delete the commit from history with git filter-repo or BFG. Having the key in a public repo's history is equivalent to having published it.


Exercise

Refactor the chat function above so that it:

  1. Receives a second parameter model: str = "gpt-4o-mini"
  2. Accepts a list of prompts and processes them in parallel using .spawn
  3. Returns the list of responses in the same order
See solution
import modal

app = modal.App("chat-parallel")
image = modal.Image.debian_slim().pip_install("openai")


@app.function(
    image=image,
    secrets=[modal.Secret.from_name("openai-secret")],
)
def chat(prompt: str, model: str = "gpt-4o-mini") -> str:
    import os
    from openai import OpenAI

    client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content


@app.local_entrypoint()
def main():
    prompts = [
        "Summarize FastAPI in one sentence",
        "Summarize Django in one sentence",
        "Summarize Flask in one sentence",
    ]
    # spawn in parallel, get() collects in order
    handles = [chat.spawn(p) for p in prompts]
    responses = [h.get() for h in handles]
    for prompt, response in zip(prompts, responses):
        print(f"\n→ {prompt}\n  {response}")

Why the solution uses .spawn and not .remote in a loop: .remote() in a loop waits for each one before starting the next (3 prompts = 3× the latency). .spawn() sends them all to Modal without waiting, and .get() collects them once they're ready in parallel.


Summary

You learned:

  • ✅ Modal functions live inside an image that you declare
  • ✅ Python dependencies are added with .pip_install(...) or .pip_install_from_requirements(...)
  • ✅ Credentials don't go in code — use modal.Secret
  • .local(), .remote() and .spawn() are three different ways to invoke the same function
  • ✅ The cold start is the cost of spinning up a container the first time; it depends a lot on what you import

Checkpoint: if you could run chat.remote("...") using a secret and OpenAI's response arrived at your terminal, you're ready.


Next capsule

In 04 — Deploying an LLM model we're going to jump to the serious stuff: deploying Mistral 7B with vLLM on an A10G GPU. You'll see:

  • How to declare gpu="A10G" in the decorator
  • How to cache the model weights on persistent disk (modal.Volume) so you don't download them on every cold start
  • How to measure how long the first inference takes vs the tenth

After that capsule you'll have an LLM endpoint running in your Modal account.


Resources

  1. Modal — Defining images — all the options for building images.
  2. Modal — Secrets — credential management.
  3. Modal — Function lifecycle@enter, @exit, container reuse.
  4. Modal — Spawn for parallel jobs — parallelization.
  5. Open-Meteo API — public API used in the example.