# Modal llms-full.txt > Modal is a platform for running Python code in the cloud with minimal > configuration, especially for serving AI models and high-performance batch > processing. It supports fast prototyping, serverless APIs, scheduled jobs, > GPU inference, distributed volumes, and sandboxes. Important notes: - Modal's primitives are embedded in Python and tailored for AI/GPU use cases, but they can be used for general-purpose cloud compute. - Modal is a serverless platform, meaning you are only billed for resources used and can spin up containers on demand in seconds. You can sign up for free at [https://modal.com] and get $30/month of credits. ## Guides ### Functions #### Functions # Modal Functions Modal Functions execute Python code using highly scalable serverless cloud compute. Preparing a function to run on Modal is as simple as defining an [App](https://modal.com/docs/guide/apps) and registering the function using the [`@app.function()`](https://modal.com/docs/sdk/py/latest/App#function) decorator: ```python app = modal.App("basic-function") @app.function() def f(x: int, exp: int) -> int: return x**exp ``` The wrapped function becomes a [`modal.Function`](https://modal.com/docs/sdk/py/latest/Function) that can be called from a local script or deployed and invoked on demand from other applications as if it were part of their codebase. When you invoke the Function, Modal handles all of the operational details: booting a container, routing your inputs, and propagating any exceptions. If you don't send more inputs, the Function will automatically scale to zero so that it incurs no ongoing cost. Information about the Function invocation, any logs it produced, and a rich set of container metrics are automatically captured and presented across a number of observability surfaces. ## Configuring the Function runtime The Function runtime is configured via arguments to the [`@app.function()`](https://modal.com/docs/sdk/py/latest/App#function) decorator. Everything about the container environment and the resources available to the Function can be defined within your Python codebase, without reference to external configuration files. Functions receive a baseline allotment of [CPU and memory](https://modal.com/docs/guide/resources). Explicit resource configuration is not necessary for simple tasks, because Functions can opportunistically burst above this baseline when needed. Heavier jobs can be provisioned with additional resources to guarantee availability: ```python @app.function(cpu=16, memory=32768) # 16 physical cores, 32 GiB of RAM def f(): ... ``` Functions can also be provisioned with one or more [GPUs](https://modal.com/docs/guide/gpu): ```python @app.function(gpu="H200:8") def f(): ... ``` Functions execute within arbitrary container environments, as defined by the Function's [Image](https://modal.com/docs/guide/images). Each Function in the App can have its own Image. Images can include resources including Python libraries from PyPI or private repositories, binary dependencies like FFmpeg or OpenCV, and data copied from your local system: ```python image = ( modal.Image.debian_slim() .uv_sync() .apt_install("ffmpeg") .add_local_dir("data", "/data") ) @app.function(image=image) def f(): ... ``` If the Function is provisioned with a GPU, the [CUDA drivers](https://modal.com/docs/guide/cuda) are automatically included. Modal includes the Function's source in the container by default. Depending on the [project structure](https://modal.com/docs/guide/project-structure), this will be either the script file or entire package where the Function's implementation is defined. As a consequence, Functions do not need to be self-contained and can reference other resources in their module. Larger datasets, such as model weights, can be mounted into the container using a Modal [Volume](https://modal.com/docs/guide/volumes) or [CloudBucketMount](https://modal.com/docs/guide/cloud-bucket-mounts): ```python vol = modal.Volume.from_name("model-weights") @app.function(volumes={"/models": vol}) def f(): ... ``` Environment variables can be defined in the container runtime by passing them as secure [Secrets](https://modal.com/docs/guide/secrets) or by setting them directly: ```python api_key = modal.Secret.from_name("api-key") @app.function(secrets=[api_key], env={"LOG_LEVEL": "info"}) def f(): ... ``` ## Function invocation Modal Functions are called by one of their [invocation methods](https://modal.com/docs/guide/function-invocation-methods), such as [`f.remote()`](https://modal.com/docs/sdk/py/latest/Function#remote) or [`f.spawn()`](https://modal.com/docs/sdk/py/latest/Function#spawn). When referenced by another Function or local entrypoint in the same App, a Function can be invoked directly: ```python @app.function() def f() -> str: return "Hello from a Modal container" @app.function() def g() -> str: return f.remote() @app.local_entrypoint() def main(): print(g.remote()) ``` Functions can be invoked from another App or from outside of Modal after a [lookup](https://modal.com/docs/guide/trigger-deployed-functions) using the App and Function names: ```python notest f = modal.Function.from_name("prod-app", "f") result = f.remote() ``` Remote lookups and invocations can also be performed via our [JavaScript](https://modal.com/docs/sdk/js/latest) and [Go](https://modal.com/docs/sdk/go/latest) SDKs, allowing you to execute code that leverages Python's AI ecosystem from within applications written in other languages: {#snippet javascript()} ```javascript notest const f = await modal.functions.fromName("prod-app", "f"); result = await f.remote(); ``` {/snippet} {#snippet go()} ```go notest f, _ := mc.Functions.FromName(ctx, "prod-app", "f", nil) result, err := f.Remote(ctx, nil, nil) ``` {/snippet} Applying one of the [Web Function](https://modal.com/docs/guide/webhooks) decorators assigns a URL for the Function and allows you to invoke it from anywhere via HTTP: ```python image = modal.Image.debian_slim().uv_pip_install("fastapi[standard]") @app.function(image=image) @modal.fastapi_endpoint() def f() -> dict[str, str]: return {"message": "Hello from a Modal container"} ``` Note that Web Functions are open to the internet by default, but they can optionally require authentication via [Proxy Tokens](https://modal.com/docs/guide/webhook-proxy-auth). Web Functions are designed for conveniently exposing simple Python functions as web services; use Modal's [Server](https://modal.com/docs/guide/servers) primitive instead for high concurrency or latency-sensitive applications. Functions can also be automatically invoked on a schedule, akin to a cron job: ```python @app.function(schedule=modal.Cron("0 6 * * *", timezone="America/New_York")) def f(): ... ``` ## Execution semantics Modal Functions abstract several principles of reliable cloud compute orchestration to present an input/output interface that looks like a local Python function call. Function invocations are automatically authenticated via your Modal token/secret credentials and authorized per your [RBAC](https://modal.com/docs/guide/rbac) configuration. The Function implementation does not need to perform access control. Modal is responsible for scheduling containers and routing your inputs to them. By default, Function containers can start anywhere in our global fleet, which maximizes availability and minimizes scheduling latency. To constrain container scheduling, e.g. for compliance, [configure the compute and routing regions](https://modal.com/docs/guide/region-selection): ```python @app.function(region="eu", routing_region="eu-west") def f(): ... ``` Note that compute region selection incurs a [pricing multiplier](https://modal.com/docs/guide/region-selection#pricing); routing region selection does not. Region selection also limits the pool of compute, especially when combined with specific GPUs or large resource requests, which can impact scheduling latency. Because container scheduling is reactive to input load, a container may not be available at the moment of invocation. Inputs will queue in Modal's I/O system until they can be distributed to available containers. If inputs are enqueued too quickly or the queue fills up, they will be rejected with a [`ResourceExhaustedError`](https://modal.com/docs/sdk/py/latest/exception#resourceexhaustederror). For batch workloads, prefer the durable [`f.spawn()`](https://modal.com/docs/sdk/py/latest/Function#spawn) method, which supports higher invocation rates and substantially deeper input queues. Modal applies an input [timeout](https://modal.com/docs/guide/timeouts) to each invocation; timeouts do not need to be set in the calling context. Timeouts are short by default (5 minutes), but they can be extended up to 24 hours for long-running processes like model training: ```python @app.function(timeout=86400) # 24 hours def f(): ... ``` Occasionally, containers will fail while executing inputs, e.g. due to [preemption](https://modal.com/docs/guide/preemption) or out-of-memory (OOM) errors. Modal automatically retries any inputs that a container was running when it failed. As a consequence, Function implementations should be idempotent. CPU functions can opt for non-preemptibility, although this incurs a pricing multiplier: ```python @app.function(nonpreemptible=True) def f(): ... ``` Exceptions that originate in the Function's implementation are not automatically retried, but input [retries](https://modal.com/docs/guide/retries) can be enabled: ```python @app.function(retries=3) def f(): ... ``` ## Autoscaling and parallelism Modal Functions autoscale by default. Just as the Function automatically boots a container in response to an initial input, it will boot additional containers if further inputs are received while it is busy. Under ongoing load, the autoscaler will manage the container pool (booting containers or scaling them down) to accommodate fluctuating levels of demand. Functions expose several options to control the [autoscaling behavior](https://modal.com/docs/guide/scale). Use `min_containers` or `buffer_containers` to reduce cold start penalties by keeping additional idle containers running, and set `max_containers` to limit scaleup under heavy demand: ```python @app.function(min_containers=1, buffer_containers=1, max_containers=20) def f(): ... ``` After a container finishes handling an input, it is available for reuse. Container reuse reduces average latency, because subsequent inputs will be handled immediately instead of waiting for a new container to boot. As load decreases, Modal will gradually scale down containers that are idle, and Functions will eventually scale to zero if inputs cease altogether. The `scaledown_window` controls the aggressiveness of this behavior: ```python @app.function(scaledown_window=600) # Idle for longer to better handle sporadic load patterns def f(): ... ``` While most Function configuration requires a redeployment to change, the autoscaler parameters can be dynamically updated using [`f.update_autoscaler()`](https://modal.com/docs/sdk/py/latest/Function#update_autoscaler): ```python notest f = modal.Function.from_name("prod-app", "f") f.update_autoscaler(max_containers=50) # Override the Function's decorator configuration ``` Note that any dynamic updates will be reset by a subsequent deployment. Because Functions autoscale rapidly, they are a good fit for bursty workloads or batch jobs that require fan-out parallelism. The batch-oriented [`f.map()`](https://modal.com/docs/sdk/py/latest/Function#map) and [`f.spawn_map()`](https://modal.com/docs/sdk/py/latest/Function#spawn_map) methods facilitate parallel execution by efficiently pushing an iterable of inputs into Modal: ```python notest for result in f.map(inputs): # Iterate in parallel and handle each result ... f.spawn_map(inputs) # Higher parallelism with durable semantics for fire-and-forget batch jobs ``` Parallel execution can also be achieved using concurrency patterns. The [`f.spawn()`](https://modal.com/docs/sdk/py/latest/Function#spawn) method returns a [`modal.FunctionCall`](https://modal.com/docs/sdk/py/latest/FunctionCall), which acts like a Future: ```python notest fc = f.spawn(x) result = fc.get() ``` Spawning multiple calls allows them to run in parallel: ```python notest fcs = [f.spawn(x_i) for x_i in xs] results = modal.FunctionCall.gather(*fcs) ``` Async codebases can also use Modal's [`aio` interface](https://modal.com/docs/guide/async) to apply concurrency patterns with any invocation method: ```python notest coros = [f.remote.aio(x_i) for x_i in xs] results = await asyncio.gather(*coros) ``` ## Container lifecycle management While Modal containers boot in less than a second, your application logic may require expensive additional setup, such as loading model weights from disk. By structuring the Function's code as a class and using the [`@app.cls()`](https://modal.com/docs/sdk/py/latest/App#cls) decorator, you can [separate the startup logic](https://modal.com/docs/guide/lifecycle-functions) from the input handling: ```python @app.cls() class InferenceEngine: @modal.enter() def setup(self): self.model = load_model() @modal.method() def predict(self, text: str) -> float: return self.model.predict(text) ``` In this example, the method wrapped with the [`@modal.enter()`](https://modal.com/docs/sdk/py/latest/enter) decorator will run only once, as part of container startup. The container will not be considered "ready" until the startup method or methods complete, and Modal will wait for this event before sending the container any inputs. A Cls is invoked by "constructing" the class and calling the method decorated with [`@modal.method()`](https://modal.com/docs/sdk/py/latest/method). As with normal Functions, this can be a local reference or a lookup: ```python notest result = InferenceEngine().predict.remote(text) # Refer to a Cls on the same App InferenceEngine = modal.Cls.from_name("prod-app", "InferenceEngine") result = InferenceEngine().predict.remote(text) # Refer to a Cls via a lookup ``` Structuring your code as a class also lets you define container teardown logic in methods wrapped with the [`@modal.exit()`](https://modal.com/docs/sdk/py/latest/exit) decorator. This is useful for cleanup operations like gracefully closing connections to databases. The exit handler can also be used to make your application more resilient to [container preemption](https://modal.com/docs/guide/preemption). Any state written to the `self` namespace will persist across the calls handled by an individual container, but it will be discarded when the container terminates. State can be shared across containers using Modal's distributed [Dict](https://modal.com/docs/guide/dicts) or [Queue](https://modal.com/docs/guide/queues) primitives. If the Function produces local state that should not leak across inputs, you can set `single_use_containers=True`. This causes each container to terminate after handling an input. Note that single-use containers add some latency and cost, since they do not benefit from amortizing container startup over multiple inputs. ## Function parametrization To write templated container lifecycle logic, add [`modal.parameter()`](https://modal.com/docs/sdk/py/latest/parameter) declarations to the class: ```python @app.cls() class InferenceEngine: model_name: str = modal.parameter() @modal.enter() def startup(self): self.model = load_model(self.model_name) @modal.method() def predict(self, input: str) -> float: ... ``` This creates a [Parametrized Function](https://modal.com/docs/guide/parametrized-functions). Supply values for the parameters when constructing the Cls in a calling context, which creates a specific instance of the Function: ```python notest result = InferenceEngine(model_name="tts-large").predict.remote(text) ``` Because the parameters apply to the entire container lifecycle, every distinct set of parameter values corresponds to a separate, independently autoscaling _container pool_. This can also be leveraged to partition a Function's containers, even when the parameter values are not read at startup. For example, you may wish to process data from different customers in separate containers: ```python notest result = PartitionedInferenceEngine(customer_id="c-024").predict.remote(text) ``` Note that there is a limit on the number of distinct instances each Function can have, so this approach is only suited for partitioning schemes with relatively low cardinality. Prefer using `single_use_containers=` for container isolation when parameter values would not frequently recur and benefit from container reuse. ## Dynamic configuration Updating configuration values in the [`@app.function()`](https://modal.com/docs/sdk/py/latest/App#function) decorator requires a [redeployment](https://modal.com/docs/guide/managing-deployments), but it's also possible to [dynamically configure](https://modal.com/docs/guide/dynamic-function-config) the Function from a call site using [`f.with_options()`](https://modal.com/docs/sdk/py/latest/Function#with_options). This is useful in cases where specific inputs or parameter values require different resources, such as different GPUs: ```python notest result = InferenceEngine(model_name="tts-large").predict.remote(text) InferenceEngineH200 = InferenceEngine.with_options(gpu="H200") result = InferenceEngineH200(model_name="tts-xlarge").predict.remote(text) ``` As with Parametrized Functions (but unlike updates to the autoscaler configuration), each distinct set of dynamic options corresponds to an independent container pool. If dynamically configuring CPU or memory, use a coarse set of values to benefit from container reuse. ## Concurrency and batching By default, each Function container will handle one input at a time. Functions support two distinct patterns for handling multiple inputs. [Input concurrency](https://modal.com/docs/guide/concurrent-inputs), enabled using the [`@modal.concurrent()`](https://modal.com/docs/sdk/py/latest/concurrent) decorator, allows Functions to accept multiple inputs and execute them concurrently using either threads or asyncio tasks: ```python @app.function() @modal.concurrent(max_inputs=10) def f(x): ... # Sync implementation; each input runs in its own thread @app.function() @modal.concurrent(max_inputs=10) async def g(x): ... # Async implementation; each input runs on the main thread in an asyncio task ``` Functions can benefit from input concurrency if they are I/O bound, e.g. because they make network requests or database queries. Some GPU frameworks can also benefit from input concurrency via continuous batching. Input concurrency is less likely to be useful if the Function is CPU bound. An alternative strategy is [dynamic batching](https://modal.com/docs/guide/dynamic-batching), enabled using the [`@modal.batched()`](https://modal.com/docs/sdk/py/latest/batched) decorator. A batched Function must be defined as accepting a list (or lists) of inputs and returning a list of outputs: ```python @app.function() @modal.batched(max_batch_size=4, wait_ms=1000) def f(x: list[int], y: list[int]) -> list[int]: return [x_i + y_i for x_i, y_i in zip(x, y)] ``` When calling a batched Function, inputs are sent individually, buffered by Modal until the batch size is filled or the wait period elapses, and then processed in a single function call. From the perspective of any individual caller, this looks no different from a normal Function invocation: ```python notest xy_sum = f.remote(2, 6) ``` Dynamic batching is especially useful in cases where you can leverage vectorization via tensor or array frameworks like torch or numpy. #### Invocation methods # Function invocation methods Modal [Functions](https://modal.com/docs/guide/functions) expose several different invocation methods. These methods have semantics that vary across multiple dimensions. Understanding how they vary will let you choose the method that is most appropriate for particular use cases. ## Synchronous vs. asynchronous Function invocations are either synchronous or asynchronous from the perspective of the calling process. Synchronous methods wait for the remote process to complete before returning the result, while asynchronous methods send the input and immediately return a [`modal.FunctionCall`](https://modal.com/docs/sdk/py/latest/FunctionCall) handle. This handle can be used to poll for progress or retrieve the result at a later time. Note that this synchronous/asynchronous distinction is unrelated to Modal's [`.aio` interface](https://modal.com/docs/guide/async). The `.aio` interface affects only the mechanism of execution in the local process, not how the call is handled by Modal's systems. It also does not matter whether the Function's implementation is written using async Python. Synchronous and asynchronous invocations differ in terms of their scalability, durability, and latency. ### Scalability Synchronous invocations are subject to stricter platform limits: - No more than 2,000 synchronous inputs may be queued and waiting for a container at any one time. - No more than 25,000 synchronous inputs in total may be in the system (queued or running) at any one time. In contrast, up to 1 million inputs can be queued for asynchronous execution, so asynchronous methods are a better choice whenever you have a large batch of inputs to process. Function calls are also subject to _rate_ limits, which are higher for asynchronous invocations. As a baseline, Modal supports synchronous invocations at a rate of 200/s and asynchronous invocations at a rate of 1,500/s. If a function call exceeds any of these limits, it will be rejected with a [`ResourceExhaustedError`](https://modal.com/docs/sdk/py/latest/exception#resourceexhaustederror). In some cases, the Modal SDK will handle this error and retry with backoff, adding latency. The exception may also be propagated to user code. ### Durability Inputs sent via asynchronous methods are more durable. Asynchronous function calls are "fire-and-forget" and will continue running if the calling process exits, but synchronous invocations will be cancelled within two minutes after the caller hangs up. The result payload for asynchronous invocations will be stored for 7 days, although the input payload will be discarded after the call completes successfully. Synchronous invocations are not stored in Modal's systems after being sent back to the caller. ### Latency Because they are handled more durably, asynchronous invocations have higher latency. For many compute-intensive applications, the difference will be negligible, but latency-sensitive applications should prefer synchronous invocation methods. Note that the synchronous I/O system still imposes some overhead to support its stateful input queue. Where request latency is at an absolute premium, prefer using Modal's [Server](https://modal.com/docs/guide/servers) primitive instead. ## Singular vs. batched Several invocation methods accept a _batch_ of inputs rather than a single input payload. These methods abstract away the mechanics involved in efficiently and reliably sending multiple inputs to Modal. Note that each input in the batch will still be _handled_ separately: this is a distinct concept from [dynamic batching](https://modal.com/docs/guide/dynamic-batching). ## Invocation methods The primary [`modal.Function`](https://modal.com/docs/sdk/py/latest/Function) invocation methods occupy the following positions in a 2x2 matrix: | | Synchronous | Asynchronous | | -------- | ---------------------------------------------------------- | ---------------------------------------------------------------- | | Singular | [`Function.remote()`](https://modal.com/docs/sdk/py/latest/Function#remote) | [`Function.spawn()`](https://modal.com/docs/sdk/py/latest/Function#spawn) | | Batched | [`Function.map()`](https://modal.com/docs/sdk/py/latest/Function#map) | [`Function.spawn_map()`](https://modal.com/docs/sdk/py/latest/Function#spawn_map) | ### `Function.remote` Invoking a Function with [`Function.remote()`](https://modal.com/docs/sdk/py/latest/Function#remote) makes a synchronous call, sending the input payload and waiting for the remote process to complete before returning. It is the most basic method for running compute on Modal because its semantics are closest to a local function call: ```python @app.function() def f(x: int) -> int: return x ** 2 @app.local_entrypoint() def main(): res = f.remote(2) assert res == 4 ``` The related [`Function.remote_gen()`](https://modal.com/docs/sdk/py/latest/Function#remote_gen) method also sends the input synchronously, but it works when the remote Function is a generator that yields results back to the caller: ```python @app.function() def g(x: int) -> int: for n in range(4): yield x ** n @app.local_entrypoint() def main(): res = g.remote_gen(2) assert list(res) == [1, 2, 4, 8] ``` ### `Function.spawn` The asynchronous [`Function.spawn()`](https://modal.com/docs/sdk/py/latest/Function#spawn) method sends its input to the Function and immediately returns a [`modal.FunctionCall`](https://modal.com/docs/sdk/py/latest/FunctionCall) object representing that input. You can retrieve the result by calling [`FunctionCall.get()`](https://modal.com/docs/sdk/py/latest/FunctionCall#get): ```python def spawn_and_fetch(x): fc = f.spawn(x) return fc.get() ``` By default, [`FunctionCall.get()`](https://modal.com/docs/sdk/py/latest/FunctionCall#get) will block until the result is available. This is similar to synchronous invocation, although it trades off some latency for scalability and durability. You can also pass a timeout to implement a polling pattern: ```python def spawn_and_poll(x): fc = f.spawn(x) while True: try: return fc.get(timeout=1) except TimeoutError: print("Not finished yet") ``` For long-running Functions, you may not want the calling process to wait until the result is available. To facilitate this, you can store the FunctionCall's object ID and use it to fetch the result in another context: ```python def spawn_input(x): fc = f.spawn(x) return fc.object_id def fetch_result(fc_id): fc = modal.FunctionCall.from_id(fc_id) return fc.get() ``` Because it offers increased scalability and durability, [`Function.spawn()`](https://modal.com/docs/sdk/py/latest/Function#spawn) is often a better choice than [`Function.remote()`](https://modal.com/docs/sdk/py/latest/Function#remote) for compute-intensive applications, especially those that require high fan-out or complex orchestration. ### `Function.map` The batched [`Function.map()`](https://modal.com/docs/sdk/py/latest/Function#map) method makes it easy to leverage Modal's horizontal scalability by consuming an iterable of inputs in a single invocation: ```python @app.function() def f(x: int) -> int: return x ** 2 @app.local_entrypoint() def main(): res = f.map(range(1, 5)) assert list(res) == [1, 4, 9, 16] ``` Modal will spin up multiple containers to process the map in parallel. The [`Function.map()`](https://modal.com/docs/sdk/py/latest/Function#map) invocation is synchronous, which has consequences for its scalability. Input submission is subject to the rate limits mentioned [above](#scalability), and each invocation can run at most 1,000 inputs concurrently. For convenience, the Modal SDK internally handles system back-pressure to avoid tripping limits on input submission rate or input queue depth while running a map. But the limits may prevent [`Function.map()`](https://modal.com/docs/sdk/py/latest/Function#map) invocations from immediately scaling up and utilizing available container capacity. The [`Function.starmap()`](https://modal.com/docs/sdk/py/latest/Function#starmap) method has equivalent semantics, but it consumes an iterable where each entry is a _sequence of arguments_, effectively doing `[f.remote(*args) for args in input_list]` in parallel. ### `Function.spawn_map` The [`Function.spawn_map()`](https://modal.com/docs/sdk/py/latest/Function#spawn_map) method combines the asynchronous semantics of [`Function.spawn()`](https://modal.com/docs/sdk/py/latest/Function#spawn) with the batched semantics of [`Function.map`](https://modal.com/docs/sdk/py/latest/Function#map). Like [`Function.map`](https://modal.com/docs/sdk/py/latest/Function#map), it applies the Function to each entry in an iterable of inputs: ```python def load_inputs(filenames): for fname in filenames: yield load(fname) def spawn_batch(filenames): f.spawn_map(load_inputs(filenames)) ``` Because platform limits are higher for asynchronous invocations, [`Function.spawn_map`](https://modal.com/docs/sdk/py/latest/Function#spawn_map) sends the entire iterable of inputs as fast as possible, taking maximum advantage of Modal's elastic compute. As yet, [`Function.spawn_map()`](https://modal.com/docs/sdk/py/latest/Function#spawn_map) does not return a FunctionCall handle, so it is currently useful only when the Function has side effects like writing its result to durable storage. This will be improved in the future. ### `Function.local` Unlike the other methods, [`Function.local()`](https://modal.com/docs/sdk/py/latest/Function#local) always executes in the same environment as the caller (whether that is on your system or inside a Modal container). Invoking [`Function.local()`](https://modal.com/docs/sdk/py/latest/Function#local) is equivalent to calling the unwrapped underlying function directly; none of the Modal configuration will apply. #### Function lookups # Invoking deployed Functions Modal Functions in [deployed Apps](https://modal.com/docs/guide/managing-deployments) can be invoked from outside of the App's source by performing a _Function lookup_: {#snippet python()} ```python notest f = modal.Function.from_name("my-app", "f") result = f.remote() ``` {/snippet} {#snippet python_async()} ```python notest f = modal.Function.from_name("my-app", "f") result = await f.remote.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const f = await modal.functions.fromName("my-app", "f"); result = await f.remote(); ``` {/snippet} {#snippet go()} ```go notest f, _ := mc.Functions.FromName(ctx, "my-app", "f", nil) result, err := f.Remote(ctx, nil, nil) ``` {/snippet} Function lookups are scoped by the name of the App, the Function's name within that App, and optionally the [environment](https://modal.com/docs/guide/environments) the App is deployed in. Note that lookups are supported only for _deployed_ Apps. Looking up a Function will fail if its App is [ephemeral](https://modal.com/docs/guide/apps#ephemeral-apps), e.g. running via the `modal serve` CLI. ## Use cases Function lookups are useful when you want to treat your Modal App as a remote service. For example, you may wish to organize your Modal codebase into multiple loosely-coupled Apps with distinct deployment lifecycles. Lookups allow Functions in these Apps to call each other as if they were members of the same App. You may also have a codebase outside of Modal that needs to execute certain operations that would benefit from Modal's scalable compute. Modal Function lookups turn that into a simple function call, automatically handling the serialization and deserialization of arguments, results, and exceptions. With Modal's [JS and Go SDKs](https://modal.com/docs/guide/sdk-javascript-go), the calling codebase does not even need to be written in Python. ## Invocation patterns Any remote invocation method can be used after looking up a Function handle. For example, you can spawn a background execution and poll its status: {#snippet python()} ```python notest f = modal.Function.from_name("my-app", "f") function_call = f.spawn(42) # Poll for the result without blocking by passing timeout=0. try: result = function_call.get(timeout=0) except TimeoutError: result = None # still running ``` {/snippet} {#snippet python_async()} ```python notest f = modal.Function.from_name("my-app", "f") function_call = await f.spawn.aio(42) # Poll for the result without blocking by passing timeout=0. try: result = await function_call.get.aio(timeout=0) except TimeoutError: result = None # still running ``` {/snippet} {#snippet javascript()} ```javascript notest const f = await modal.functions.fromName("my-app", "f"); const functionCall = await f.spawn([42]); // Poll for the result without blocking by passing timeoutMs: 0. let result; try { result = await functionCall.get({ timeoutMs: 0 }); } catch (err) { if (!(err instanceof FunctionTimeoutError)) throw err; result = null; // still running } ``` {/snippet} {#snippet go()} ```go notest f, _ := mc.Functions.FromName(ctx, "my-app", "f", nil) functionCall, _ := f.Spawn(ctx, []any{42}, nil) // Poll for the result without blocking by passing a zero *time.Duration timeout zero := time.Duration(0) result, err := functionCall.Get(ctx, &modal.FunctionCallGetParams{Timeout: &zero}) // A non-nil err indicates the call is still running. ``` {/snippet} Or you can distribute embarrassingly parallel work across multiple containers: {#snippet python()} ```python notest f = modal.Function.from_name("my-app", "f") results = list(f.map(range(5))) ``` {/snippet} {#snippet python_async()} ```python notest f = modal.Function.from_name("my-app", "f") results = [result async for result in f.map.aio(range(5))] ``` {/snippet} Note: `Function.map()` is currently supported only in Python. When your Function is defined as a Modal Cls, you can pass [parameters](https://modal.com/docs/guide/parametrized-functions) and invoke specific methods after a lookup: {#snippet python()} ```python notest Model = modal.Cls.from_name("my-app", "Model") obj = Model(size="35B") result = obj.generate.remote("hello") ``` {/snippet} {#snippet python_async()} ```python notest Model = modal.Cls.from_name("my-app", "Model") obj = Model(size="35B") result = await obj.generate.remote.aio("hello") ``` {/snippet} {#snippet javascript()} ```javascript notest const cls = await modal.cls.fromName("my-app", "Model"); const obj = await cls.instance({ size: "35B" }); const generate = obj.method("generate"); const result = await generate.remote(["hello"]); ``` {/snippet} {#snippet go()} ```go notest cls, _ := mc.Cls.FromName(ctx, "my-app", "Model", nil) obj, _ := cls.Instance(ctx, map[string]any{"size": "35B"}) generate, _ := obj.Method("generate") result, _ := generate.Remote(ctx, []any{"hello"}, nil) ``` {/snippet} It's also possible to [dynamically configure](https://modal.com/docs/guide/dynamic-function-config) a Function or Cls via a remote lookup. For example, you can select a GPU type that aligns with the specific model you are invoking: {#snippet python()} ```python notest Model = modal.Cls.from_name("my-app", "Model") obj = Model.with_options(gpu="H100")(size="35B") result = obj.generate.remote("hello") ``` {/snippet} {#snippet python_async()} ```python notest Model = modal.Cls.from_name("my-app", "Model") obj = Model.with_options(gpu="H100")(size="35B") result = await obj.generate.remote.aio("hello") ``` {/snippet} {#snippet javascript()} ```javascript notest const cls = await modal.cls.fromName("my-app", "Model"); const obj = await cls.withOptions({ gpu: "H100" }).instance({ size: "35B" }); const generate = obj.method("generate"); const result = await generate.remote(["hello"]); ``` {/snippet} {#snippet go()} ```go notest cls, _ := mc.Cls.FromName(ctx, "my-app", "Model", nil) gpu := "H100" obj, _ := cls. WithOptions(&modal.ClsWithOptionsParams{GPU: &gpu}). Instance(ctx, map[string]any{"size": "35B"}) generate, _ := obj.Method("generate") result, _ := generate.Remote(ctx, []any{"hello"}, nil) ``` {/snippet} ## Version-pinned lookups Version-pinned lookups are available on the Team and Enterprise plans. Visit workspace settings to upgrade. All Function invocations will route to the "latest" available version of the App by default. During a [rolling deployment](https://modal.com/docs/guide/managing-deployments#deployment-strategies), this may correspond to an outdated version, but repeated invocation of the Function handle will eventually reach the most recent deploy without any need to refresh the handle. It's also possible to look up a specific version of the App, which returns a "version-pinned" Function handle: {#snippet python()} ```python notest f = modal.Function.from_name("my-app", "f", version=3) result = f.remote() ``` {/snippet} {#snippet python_async()} ```python notest f = modal.Function.from_name("my-app", "f", version=3) result = await f.remote.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const f = await modal.functions.fromName("my-app", "f", { version: 3 }); result = await f.remote(); ``` {/snippet} {#snippet go()} ```go notest f, _ := mc.Functions.FromName(ctx, "my-app", "f", &modal.FunctionFromNameParams{Version: 3}) result, err := f.Remote(ctx, nil, nil) ``` {/snippet} If the version-pinned Function directly calls other Functions in the same App, those calls will also be guaranteed to run on the same version (which is not generally the case across deployments, even for calls within the same App). Version-pinned invocations have a few tradeoffs. Principally, version-pinned invocations will be handled by a distinct pool of containers with special rules around autoscaling: - Containers handling version-pinned invocations are not included in the Function's main `max_containers` budget. Instead, the limit will be applied at the level of _individual versions_. You must account for this if each container consumes a limited resource (e.g., a connection to a database). - Version-pinned Functions will ignore the `min_containers` configuration in the Function decorator, and they will not maintain a warm pool by default. If this is desired, the `Function.update_autoscaler()` method can be used to dynamically configure a warm pool. It is the user's responsibility to scale the warm pool down after it is no longer needed. Version pinning is supported only for App versions within your retention window (i.e., versions that you could also roll back to). Longer retention windows are available on the Enterprise plan. ## Authentication Function lookups are authenticated via Modal [API tokens](https://modal.com/settings/tokens). These tokens implicitly specify the Workspace targeted by the lookup. Tokens are automatically read from the active profile in your `~/.modal.toml` file. They can also be configured via the `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` environment variables. These take precedence over the `~/.modal.toml` when set. ## Limitations While you can use any remote invocation method on a Function handle after a lookup, `.local()` invocation is not supported, because the implementation will not be available locally. Unlike with remote calls between Functions in the same Python App, the Function interfaces will not be legible to type checkers after a lookup. Your code will have to explicitly narrow the result to treat it as a concrete type. ## Invoking with HTTPS Modal [Web Functions](https://modal.com/docs/guide/webhooks) can be invoked via HTTPS at a [public URL](https://modal.com/docs/guide/webhook-urls). Unlike Function lookups via one of our SDKs, Web Functions are not authenticated by default, and authenticated Web Functions use [Proxy Tokens](https://modal.com/docs/guide/webhook-proxy-auth) instead of Modal API tokens. Web Functions can be invoked from web browsers, from Unix tools like `curl`, or from any language with an HTTPS client. #### Container lifecycle management # Container lifecycle hooks Since Modal will reuse the same container for multiple inputs, sometimes you might want to run some code exactly once when the container starts or exits. To accomplish this, you need to use Modal's class syntax and the [`@app.cls`](https://modal.com/docs/sdk/py/latest/App#cls) decorator. Specifically, you'll need to: 1. Convert your function to a method by making it a member of a class. 2. Decorate the class with `@app.cls(...)` with same arguments you previously had for `@app.function(...)`. 3. Instead of the `@app.function` decorator on the original method, use `@modal.method` or the appropriate decorator for a [Web Function](#lifecycle-hooks-for-web-functions). 4. Add the correct method "hooks" to your class based on your need: - `@modal.enter` for one-time initialization (remote) - `@modal.exit` for one-time cleanup (remote) ## `@modal.enter` The container entry handler is called when a new container is started. This is useful for doing one-time initialization, such as loading model weights or importing packages that are only present in that image. To use, make your function a member of a class, and apply the `@modal.enter()` decorator to one or more class methods: ```python import modal app = modal.App() @app.cls(cpu=8) class Model: @modal.enter() def run_this_on_container_startup(self): import pickle self.model = pickle.load(open("model.pickle")) @modal.method() def predict(self, x): return self.model.predict(x) @app.local_entrypoint() def main(): Model().predict.remote(x=123) ``` When working with an [asynchronous Modal](https://modal.com/docs/guide/async) app, you may use an async method instead: ```python import modal app = modal.App() @app.cls(memory=1024) class Processor: @modal.enter() async def my_enter_method(self): self.cache = await load_cache() @modal.method() async def run(self, x): return await do_some_async_stuff(x, self.cache) @app.local_entrypoint() async def main(): await Processor().run.remote(x=123) ``` Note: The `@modal.enter()` decorator replaces the earlier `__enter__` syntax, which has been deprecated. ## `@modal.exit` The container exit handler is called when a container is about to exit. It is useful for doing one-time cleanup, such as closing a database connection or saving intermediate results. To use, make your function a member of a class, and apply the `@modal.exit()` decorator: ```python import modal app = modal.App() @app.cls() class ETLPipeline: @modal.enter() def open_connection(self): import psycopg2 self.connection = psycopg2.connect(os.environ["DATABASE_URI"]) @modal.method() def run(self): # Run some queries pass @modal.exit() def close_connection(self): self.connection.close() @app.local_entrypoint() def main(): ETLPipeline().run.remote() ``` Exit handlers are also called when a container is [preempted](https://modal.com/docs/guide/preemption). The exit handler is given a grace period of 30 seconds to finish, and it will be killed if it takes longer than that to complete. ## Lifecycle hooks for Web Functions Modal [Web Functions](https://modal.com/docs/guide/webhooks) can be converted to the class syntax as well. Instead of `@modal.method`, simply use whichever Web Function decorator (`@modal.fastapi_endpoint`, `@modal.asgi_app` or `@modal.wsgi_app`) you were using before. ```python from fastapi import Request import modal image = modal.Image.debian_slim().pip_install("fastapi") app = modal.App("web-function-cls", image=image) @app.cls() class Model: @modal.enter() def run_this_on_container_startup(self): self.model = pickle.load(open("model.pickle")) @modal.fastapi_endpoint() def predict(self, request: Request): ... ``` #### Parametrized Functions # Parametrized functions A single Modal Function can be parametrized by a set of arguments, so that each unique combination of arguments will behave like an individual Modal Function with its own auto-scaling and lifecycle logic. For example, you might want to have a separate pool of containers for each unique user that invokes your Function. In this scenario, you would parametrize your Function by a user ID. To parametrize a Modal Function, you need to use Modal's [class syntax](https://modal.com/docs/guide/lifecycle-functions) and the [`@app.cls`](https://modal.com/docs/sdk/py/latest/App#cls) decorator. Specifically, you'll need to: 1. Convert your function to a method by making it a member of a class. 2. Decorate the class with `@app.cls(...)` with the same arguments you previously had for `@app.function(...)` or your [Web Function decorator](https://modal.com/docs/guide/webhooks). 3. If you previously used the `@app.function()` decorator on your function, replace it with `@modal.method()`. 4. Define dataclass-style, type-annotated instance attributes with `modal.parameter()` and optionally set default values: ```python import modal app = modal.App() @app.cls() class MyClass: foo: str = modal.parameter() bar: int = modal.parameter(default=10) @modal.method() def baz(self, qux: str = "default") -> str: return f"This code is running in container pool ({self.foo}, {self.bar}), with input qux={qux}" ``` The parameters create a keyword-only constructor for your class, and the methods can be called as follows: ```python @app.local_entrypoint() def main(): m1 = MyClass(foo="hedgehog", bar=7) m1.baz.remote() m2 = MyClass(foo="fox") m2.baz.remote(qux="override") ``` Function calls for each unique combination of values for `foo` and `bar` will run in their own separate container pools. If you re-constructed a `MyClass` with the same arguments in a different context, the calls to `baz` would be routed to the same set of containers as before. Some things to note: - The total size of the arguments is limited to 16 KiB. - Modal classes can still annotate types of regular class attributes, which are independent of parametrization, by either omitting `= modal.parameter()` or using `= modal.parameter(init=False)` to satisfy type checkers. - The support types are these primitives: `str`, `int`, `bool`, and `bytes`. - The legacy `__init__` constructor method is being removed, see [the 1.0 migration for details.](https://modal.com/docs/guide/modal-1-0-migration#removing-support-for-custom-cls-constructors) ## Looking up a parametrized function If you want to call your parametrized function from a Python script running anywhere, you can use `Cls.lookup`: ```python notest import modal MyClass = modal.Cls.from_name("parametrized-function-app", "MyClass") # returns a class-like object m = MyClass(foo="snake", bar=12) m.baz.remote() ``` ## Parametrized Web Functions Modal [Web Functions](https://modal.com/docs/guide/webhooks) can also be parametrized: ```python app = modal.App("parametrized-endpoint") @app.cls() class MyClass(): foo: str = modal.parameter() bar: int = modal.parameter(default=10) @modal.fastapi_endpoint() def baz(self, qux: str = "default") -> str: ... ``` Parameters are specified in the URL as query parameter values. ```bash curl "https://parametrized-endpoint.modal.run?foo=hedgehog&bar=7&qux=override" curl "https://parametrized-endpoint.modal.run?foo=hedgehog&qux=override" curl "https://parametrized-endpoint.modal.run?foo=hedgehog&bar=7" curl "https://parametrized-endpoint.modal.run?foo=hedgehog" ``` ## Using parametrized functions with lifecycle functions Parametrized functions can be used with [lifecycle functions](https://modal.com/docs/guide/lifecycle-functions). For example, here is how you might parametrize the [`@modal.enter`](https://modal.com/docs/guide/lifecycle-functions#enter) lifecycle function to load a specific model: ```python @app.cls() class Model: name: str = modal.parameter() size: int = modal.parameter(default=100) @modal.enter() def load_model(self): print(f"Loading model {self.name} with size {self.size}") self.model = load_model_util(self.name, self.size) @modal.method() def generate(self, prompt: str) -> str: return self.model.generate(prompt) ``` ## Performance Currently, parametrized Function creation is rate limited to 1 per second, with the ability to burst to 1000. Please [get in touch](mailto:support@modal.com) if you need higher rate limits. #### Dynamic configuration # Dynamic Function configuration Many aspects of a Modal Function's configuration can be dynamically configured from a specific call site. This is useful in cases where the Function's [compute resources](https://modal.com/docs/guide/resources), [secrets](https://modal.com/docs/guide/secrets), [timeout](https://modal.com/docs/guide/timeouts), or other properties need to vary depending on the specific inputs. ## Basic configuration Features exposed in the [`@app.function()`](https://modal.com/docs/sdk/py/latest/App#function) decorator can be dynamically configured at runtime with the [`modal.Function.with_options()`](https://modal.com/docs/sdk/py/latest/Function#with_options) method. Say you have the following definition: ```python @app.function() def f(x: int) -> int: return x ** 2 ``` If (for some reason) you wanted to compare this Function's output across several different GPUs, you could invoke it several times with different configurations: ```python continuation @app.local_entrypoint() def main(): for gpu in ["T4", "L4", "A10"]: result = f.with_options(gpu=gpu).remote(2) print(f"Result with {gpu} GPU: {result}") ``` This example creates three additional variants of the base Function after the App is already running. These variants are _new Functions_ that are created on-demand. The base Function itself is not affected. If you invoked `f.remote()` directly, it would continue to execute without a GPU. Deployed Functions can also be dynamically configured from a call site after a lookup: ```python notest deployed_f = modal.Function.from_name("demo-app", "f") for gpu in ["T4", "L4", "A10"]: result = deployed_f.with_options(gpu=gpu).remote(2) print(f"Result with {gpu} GPU: {result}") ``` ## Input concurrency and batching It's also possible to dynamically configure [input concurrency](https://modal.com/docs/guide/concurrent-inputs) or [batching](https://modal.com/docs/guide/dynamic-batching). As these features are enabled with separate decorators ([`@modal.concurrent()`](https://modal.com/docs/sdk/py/latest/concurrent)/[`@modal.batched()`](https://modal.com/docs/sdk/py/latest/batched)), their dynamic configuration runs through separate methods ([`modal.Function.with_concurrency()`](https://modal.com/docs/sdk/py/latest/Function#with_concurrency)/[`modal.Function.with_batching()`](https://modal.com/docs/sdk/py/latest/Function#with_batching)): ```python notest concurrent_f = modal.Function.from_name("demo-app", "f").with_concurrency(max_inputs=32) ``` If multiple dynamic configuration methods are called in sequence, their arguments will compose and form a single configuration: ```python notest # This Function uses a GPU with input concurrency concurrent_f.with_options(gpu="H100").remote(...) ``` ## Autoscaling considerations Each distinct configuration has its own dedicated autoscaling container pool. By default, the container pool will autoscale according to the configuration of the base Function, with separate accounting. For example, if your Function has `@app.function(max_containers=5)` and you dynamically add a GPU using `f.with_options(gpu="H100")`, you'll get up to 5 _additional_ H100 containers regardless of how many CPU containers are currently running. Try to avoid generating too many fine-grained configurations so that you can benefit from container sharing for higher utilization and reduced cold start latencies. For example, if requesting input-specific `memory=` or `cpu=` resources, it's best to round into coarse buckets. Functions that have been looked up and dynamically configured in separate processes will still share containers if they apply the same configuration. If your base Function configuration has `min_containers` set, it will be ignored by the Function variants to avoid creating zombie warm pools. For the same reason, it's not possible to set `min_containers` in `modal.Function.with_options()`. It is possible to dynamically configure other aspects of autoscaling behavior using `modal.Function.with_options()`. For example, if you don't expect to re-use the variant, you could reduce the `scaledown_window` so that the container shuts down faster. However, if your goal is to use different autoscaling policies over time, it may be simpler to modify the base Function's behavior using [`modal.Function.update_autoscaler`](https://modal.com/docs/sdk/py/latest/Function#update_autoscaler) instead. ## Dynamic Cls configuration It's also possible to dynamically configure a `modal.Cls`. If the Cls is [parametrized](https://modal.com/docs/guide/parametrized-functions) (which also creates a new Function variant with its own container pool and autoscaling accounting), the dynamic options will compose with the parameter values: ```python notest ModelCls = modal.Cls.from_name("demo-app", "ModelCls") model = ModelCls.with_options(gpu="H100")(size="8B") ``` ### Sandboxes #### Sandboxes # Sandboxes This page is a high-level guide to Sandboxes, secure containers for executing untrusted user or agent code on Modal. For reference documentation on the `modal.Sandbox` interface, see [this page](https://modal.com/docs/sdk/py/latest/Sandbox). ## What are Sandboxes and why should I use them? In addition to the Function interface, Modal has a direct interface for defining containers _at runtime_ and securely running arbitrary code inside them. This can be useful if, for example, you want to: - Execute code generated by a language model. - Create isolated environments for running untrusted code. - Check out a git repository and run a command against it, like a test suite, or `npm lint`. - Run containers with arbitrary dependencies and setup scripts. Each individual job is called a **Sandbox** and can be created using the [`Sandbox.create`](https://modal.com/docs/sdk/py/latest/Sandbox#create) constructor: {#snippet python()} ```python sb_app = modal.App.lookup("my-app", create_if_missing=True) sb = modal.Sandbox.create(app=sb_app) p = sb.exec("python", "-c", "print('hello')", timeout=3) print(p.stdout.read()) p = sb.exec("bash", "-c", "for i in {1..10}; do date +%T; sleep 0.5; done", timeout=5) for line in p.stdout: # Avoid double newlines by using end="". print(line, end="") sb.terminate() sb.detach() ``` {/snippet} {#snippet python_async()} ```python sb_app = await modal.App.lookup.aio("my-app", create_if_missing=True) sb = await modal.Sandbox.create.aio(app=sb_app) p = await sb.exec.aio("python", "-c", "print('hello')", timeout=3) print(await p.stdout.read.aio()) p = await sb.exec.aio("bash", "-c", "for i in {1..10}; do date +%T; sleep 0.5; done", timeout=5) async for line in p.stdout: # Avoid double newlines by using end="". print(line, end="") await sb.terminate.aio() await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const modal = new ModalClient(); const app = await modal.apps.fromName("my-app", { createIfMissing: true, }); const image = modal.images.fromRegistry("python:3.13-slim"); const sb = await modal.sandboxes.create(app, image); const p = await sb.exec(["python", "-c", "print('hello')"], { timeoutMs: 3 * 1000, }); console.log(await p.stdout.readText()); const p2 = await sb.exec( ["bash", "-c", "for i in {1..10}; do date +%T; sleep 0.5; done"], { timeoutMs: 5 * 1000 }, ); for await (const line of p2.stdout) { process.stdout.write(line); } await sb.terminate(); ``` {/snippet} {#snippet go()} ```go notest package main import ( "context" "fmt" "io" "os" "time" modal "github.com/modal-labs/modal-client/go" ) func main() { ctx := context.Background() mc, _ := modal.NewClient() app, _ := mc.Apps.FromName(ctx, "my-app", &modal.AppFromNameParams{ CreateIfMissing: true, }) image := mc.Images.FromRegistry("python:3.13-slim", nil) sb, _ := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Terminate(ctx, nil) p, _ := sb.Exec(ctx, []string{"python", "-c", "print('hello')"}, &modal.SandboxExecParams{ Timeout: 3 * time.Second, }) stdout, _ := io.ReadAll(p.Stdout) fmt.Println(string(stdout)) p2, _ := sb.Exec(ctx, []string{"bash", "-c", "for i in {1..10}; do date +%T; sleep 0.5; done"}, &modal.SandboxExecParams{ Timeout: 5 * time.Second, }) io.Copy(os.Stdout, p2.Stdout) } ``` {/snippet} **Note:** you can run the above example as a script directly with `python my_script.py`. `modal run` is not needed here since there is no [entrypoint](https://modal.com/docs/guide/apps#entrypoints-for-ephemeral-apps). Sandboxes require an [`App`](https://modal.com/docs/guide/apps) to be passed when spawned from outside of a Modal container. You may pass in a regular `App` object or look one up by name with [`App.lookup`](https://modal.com/docs/sdk/py/latest/App#lookup). The `create_if_missing` flag on `App.lookup` will create an `App` with the given name if it doesn't exist. ## Lifecycle ### Events Every Sandbox moves through a series of lifecycle events as it progresses from creation to completion. Understanding these events is useful for monitoring, debugging, and building automations that react to Sandbox state changes. The lifecycle events, in order, are: 1. **Created** — The Sandbox has been requested and registered with Modal. At this point the Sandbox object exists and has an ID, but no compute resources have been allocated yet. This is the initial state immediately after calling `Sandbox.create`. 2. **Scheduled** — The Sandbox has been scheduled to a specific worker. The worker is now provisioning the resources the Sandbox needs (CPU, memory, GPU, volumes, etc.) and preparing the container environment. The Sandbox will transition to **Started** once the container is fully initialized. 3. **Started** — The Sandbox's container has been launched on a worker and the entrypoint process (if any) is running. At this point you can begin executing commands inside the Sandbox with `sandbox.exec(...)`. Network tunnels and volume mounts are active. 4. **Ready** — If [readiness probes](https://modal.com/docs/guide/sandboxes#readiness-probes) are enabled for the Sandbox, this event fires once the probe succeeds, indicating that the service inside the Sandbox is fully initialized and ready to accept traffic. This is especially useful for Sandboxes running web servers or other services that need warm-up time before they can handle requests. If readiness probes are not configured, this event is skipped. 5. **Finished** — The Sandbox has stopped running. This can happen for several reasons: the entrypoint process exited on its own, the Sandbox was explicitly terminated (via the dashboard or `sandbox.terminate()`), the timeout or idle timeout was reached, or an out-of-memory condition occurred. Once finished, no further commands can be executed inside the Sandbox. You can learn more about why a Sandbox stopped running in the dashboard or by examining the exit code returned from `sandbox.poll()`. ### Timeouts Sandboxes have a default maximum lifetime of 5 minutes. You can change this by passing a `timeout` of up to 24 hours to the `Sandbox.create(...)` function. {#snippet python()} ```python fixture:sb_app sb = modal.Sandbox.create(app=sb_app, timeout=10*60) # 10 minutes sb.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sb = await modal.Sandbox.create.aio(app=sb_app, timeout=10*60) # 10 minutes await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image, { timeoutMs: 10 * 60 * 1000, // 10 minutes }); sb.detach(); ``` {/snippet} {#snippet go()} ```go notest sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Timeout: 10 * time.Minute, }) defer sb.Detach() ``` {/snippet} If you need a Sandbox to run for more than 24 hours, we recommend using [Filesystem Snapshots](https://modal.com/docs/guide/sandbox-snapshots) to preserve its state, and then restore from that snapshot with a subsequent Sandbox. ### Idle Timeouts Sandboxes can also be automatically terminated after a period of inactivity - you can do this by setting the `idle_timeout` parameter. A Sandbox is considered active if any of the following are true: 1. It has an active [command](https://modal.com/docs/guide/sandbox-spawn) running (via [`sb.exec(...)`](https://modal.com/docs/sdk/py/latest/Sandbox#exec)) 2. Its stdin is being written to (via [`sb.stdin.write()`](https://modal.com/docs/sdk/py/latest/Sandbox#stdin)) 3. It has an open TCP connection over one of its [Tunnels](https://modal.com/docs/guide/tunnels) ### Readiness Probes After a Sandbox starts, you often need to run custom initialization logic before it's ready for use — pulling code with `git pull`, installing dependencies, starting a server, writing config files, or other setup that isn't baked into the image. Readiness probes give you a way to track when that initialization is complete, so you don't have to build the polling or signaling yourself. Modal also uses probe results to give you observability into how long this startup phase typically takes. A readiness probe is a check that Modal runs automatically inside the Sandbox at a configurable interval. You can then call `wait_until_ready()` to block until the probe succeeds. There are two types of readiness probes: - **TCP probe** — Checks whether a TCP port inside the Sandbox is accepting connections. This is the most common choice when your startup logic includes launching a server. - **Exec probe** — Runs an arbitrary command inside the Sandbox and succeeds when the command exits with status code 0. Use this for any other readiness condition: checking that a file exists, verifying a setup script has completed, confirming dependencies are installed, etc. Both probe types accept an `interval_ms` parameter (default: 100ms) that controls how frequently the check is retried until it succeeds. #### TCP readiness probe Use a TCP probe when your Sandbox starts a server and you want to wait until it's listening on a port: {#snippet python()} ```python fixture:sb_app sb = modal.Sandbox.create( "python3", "-m", "http.server", "8080", readiness_probe=modal.Probe.with_tcp(8080), app=sb_app, ) # Blocks until port 8080 is accepting connections sb.wait_until_ready() # The server is now ready — interact with it via tunnels, exec, etc. sb.terminate() sb.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sb = await modal.Sandbox.create.aio( "python3", "-m", "http.server", "8080", readiness_probe=modal.Probe.with_tcp(8080), app=sb_app, ) # Blocks until port 8080 is accepting connections await sb.wait_until_ready.aio() # The server is now ready — interact with it via tunnels, exec, etc. await sb.terminate.aio() await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const modal = new ModalClient(); const app = await modal.apps.fromName("my-app", { createIfMissing: true }); const image = modal.images.fromRegistry("python:3.13-slim"); const sb = await modal.sandboxes.create(app, image, { command: ["python3", "-m", "http.server", "8080"], readinessProbe: Probe.withTcp(8080), }); // Blocks until port 8080 is accepting connections await sb.waitUntilReady(); // The server is now ready — interact with it via tunnels, exec, etc. await sb.terminate(); ``` {/snippet} {#snippet go()} ```go notest package main import ( "context" "time" modal "github.com/modal-labs/modal-client/go" ) func main() { ctx := context.Background() mc, _ := modal.NewClient() app, _ := mc.Apps.FromName(ctx, "my-app", &modal.AppFromNameParams{ CreateIfMissing: true, }) image := mc.Images.FromRegistry("python:3.13-slim", nil) probe, _ := modal.NewTCPProbe(8080, nil) sb, _ := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"python3", "-m", "http.server", "8080"}, ReadinessProbe: probe, }) defer sb.Detach() // Blocks until port 8080 is accepting connections sb.WaitUntilReady(ctx, 5*time.Minute) // The server is now ready — interact with it via tunnels, exec, etc. sb.Terminate(ctx, nil) } ``` {/snippet} #### Exec readiness probe Use an exec probe when readiness depends on something other than a TCP port — for example, waiting for a file to be created or a setup script to complete: {#snippet python()} ```python fixture:sb_app sb = modal.Sandbox.create( "bash", "-c", "sleep 5 && touch /tmp/ready && sleep 3600", readiness_probe=modal.Probe.with_exec( "sh", "-c", "test -f /tmp/ready", interval_ms=250, ), app=sb_app, ) # Blocks until "test -f /tmp/ready" exits with code 0 sb.wait_until_ready() # The sandbox is now ready p = sb.exec("cat", "/tmp/ready") sb.terminate() sb.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sb = await modal.Sandbox.create.aio( "bash", "-c", "sleep 5 && touch /tmp/ready && sleep 3600", readiness_probe=modal.Probe.with_exec( "sh", "-c", "test -f /tmp/ready", interval_ms=250, ), app=sb_app, ) # Blocks until "test -f /tmp/ready" exits with code 0 await sb.wait_until_ready.aio() # The sandbox is now ready p = await sb.exec.aio("cat", "/tmp/ready") await sb.terminate.aio() await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const modal = new ModalClient(); const app = await modal.apps.fromName("my-app", { createIfMissing: true }); const image = modal.images.fromRegistry("python:3.13-slim"); const sb = await modal.sandboxes.create(app, image, { command: ["bash", "-c", "sleep 5 && touch /tmp/ready && sleep 3600"], readinessProbe: Probe.withExec(["sh", "-c", "test -f /tmp/ready"], { intervalMs: 250, }), }); // Blocks until "test -f /tmp/ready" exits with code 0 await sb.waitUntilReady(); // The sandbox is now ready await sb.terminate(); ``` {/snippet} {#snippet go()} ```go notest package main import ( "context" "time" modal "github.com/modal-labs/modal-client/go" ) func main() { ctx := context.Background() mc, _ := modal.NewClient() app, _ := mc.Apps.FromName(ctx, "my-app", &modal.AppFromNameParams{ CreateIfMissing: true, }) image := mc.Images.FromRegistry("python:3.13-slim", nil) probe, _ := modal.NewExecProbe( []string{"sh", "-c", "test -f /tmp/ready"}, &modal.ExecProbeParams{Interval: 250 * time.Millisecond}, ) sb, _ := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"bash", "-c", "sleep 5 && touch /tmp/ready && sleep 3600"}, ReadinessProbe: probe, }) defer sb.Detach() // Blocks until "test -f /tmp/ready" exits with code 0 sb.WaitUntilReady(ctx, 5*time.Minute) // The sandbox is now ready sb.Terminate(ctx, nil) } ``` {/snippet} **Note:** Readiness probes will run for a maximum of 5 minutes. If the probe does not succeed within that window, `wait_until_ready()` will raise a `modal.exception.TimeoutError`. This is Modal's own error class rather than the builtin `TimeoutError`, so a bare `except TimeoutError` will not catch it. The probe timeout does **not** automatically terminate the Sandbox — you may want to catch the error and explicitly terminate the Sandbox if readiness is never achieved: {#snippet python()} ```python notest try: sb.wait_until_ready() except modal.exception.TimeoutError: print("Sandbox failed to become ready") sb.terminate() sb.detach() ``` {/snippet} {#snippet python_async()} ```python notest try: await sb.wait_until_ready.aio() except modal.exception.TimeoutError: print("Sandbox failed to become ready") await sb.terminate.aio() await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest try { await sb.waitUntilReady(); } catch (err) { console.log("Sandbox failed to become ready"); await sb.terminate(); } ``` {/snippet} {#snippet go()} ```go notest if err := sb.WaitUntilReady(ctx, 5*time.Minute); err != nil { fmt.Println("Sandbox failed to become ready") sb.Terminate(ctx, nil) } ``` {/snippet} If you call `wait_until_ready()` on a Sandbox that was not configured with a readiness probe, an error will be raised. Similarly, calling it after the Sandbox has been terminated will raise an error. However, calling `wait_until_ready()` after the Sandbox has already become ready returns immediately. ## Return Codes [Unix-style exit codes](https://tldp.org/LDP/abs/html/exitcodes.html) are provided to help diagnose conditions such as success, manual termination, or out-of-memory. They are available on both: - Processes in the sandbox (via [`ContainerProcess.returncode`](https://modal.com/docs/sdk/py/latest/container_process#returncode) / [`ContainerProcess.poll()`](https://modal.com/docs/sdk/py/latest/container_process#poll)) - The Sandbox itself (via [`Sandbox.returncode`](https://modal.com/docs/sdk/py/latest/Sandbox#returncode) / [`Sandbox.poll()`](https://modal.com/docs/sdk/py/latest/Sandbox#poll)) {#snippet python()} ```python fixture:sb_app sb = modal.Sandbox.create(app=sb_app) # Read returncode of individual process p = sb.exec("sh", "-c", "exit 42") p.wait() print(p.returncode) # 42 # Read returncode of finished sandbox # Terminate sends a SIGKILL, code 137 sb.terminate(wait=True) print(sb.returncode) # 137 ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sb = await modal.Sandbox.create.aio(app=sb_app) # Read returncode of individual process p = await sb.exec.aio("sh", "-c", "exit 42") await p.wait.aio() print(p.returncode) # 42 # Read returncode of finished sandbox # Terminate sends a SIGKILL, code 137 await sb.terminate.aio(wait=True) print(sb.returncode) # 137 ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image); // Read returncode of individual process const p = await sb.exec(["sh", "-c", "exit 42"]); const returnCode = await p.wait(); console.log(returnCode); // 42 // Read returncode of finished sandbox // Terminate sends a SIGKILL, code 137 const returnCodeSb = await sb.terminate({ wait: true }); console.log(returnCodeSb); // 137 ``` {/snippet} {#snippet go()} ```go notest sb, _ := mc.Sandboxes.Create(ctx, app, image, nil) // Read returncode of individual process p, _ := sb.Exec(ctx, []string{"sh", "-c", "exit 42"}, nil) returnCode, _ := p.Wait(ctx) fmt.Println(returnCode) // 42 // Read returncode of finished sandbox // Terminate sends a SIGKILL, code 137 returnCodeSb, _ := sb.Terminate(ctx, &modal.SandboxTerminateParams{Wait: true}) fmt.Println(returnCodeSb) // 137 ``` {/snippet} ## Configuration Sandboxes support nearly all configuration options found in regular `modal.Function`s. Refer to [`Sandbox.create`](https://modal.com/docs/sdk/py/latest/Sandbox#create) for further documentation on Sandbox configs. For example, Images and Volumes can be used just as with functions: {#snippet python()} ```python fixture:sb_app sb = modal.Sandbox.create( image=modal.Image.debian_slim().pip_install("pandas"), volumes={"/data": modal.Volume.from_name("data-volume", create_if_missing=True)}, app=sb_app, ) sb.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sb = await modal.Sandbox.create.aio( image=modal.Image.debian_slim().pip_install("pandas"), volumes={"/data": modal.Volume.from_name("data-volume", create_if_missing=True)}, app=sb_app, ) await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const image = modal.images.fromRegistry("python:3.13-slim"); const volume = modal.volumes.fromName("my-volume"); const sb = await modal.sandboxes.create(app, image, { volumes: { "/data": volume }, workdir: "/repo", }); sb.detach(); ``` {/snippet} {#snippet go()} ```go notest image := mc.Images.FromRegistry("python:3.13-slim", nil) volume := mc.Volumes.FromName("my-volume", nil) sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Volumes: map[string]*modal.Volume{"/data": volume}, Workdir: "/repo", }) defer sb.Detach() ``` {/snippet} ## Environments ### Environment variables You can set environment variables using inline secrets: {#snippet python()} ```python fixture:sb_app secret = modal.Secret.from_dict({"MY_SECRET": "hello"}) sb = modal.Sandbox.create( secrets=[secret], app=sb_app, ) p = sb.exec("bash", "-c", "echo $MY_SECRET") print(p.stdout.read()) sb.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app secret = modal.Secret.from_dict({"MY_SECRET": "hello"}) sb = await modal.Sandbox.create.aio( secrets=[secret], app=sb_app, ) p = await sb.exec.aio("bash", "-c", "echo $MY_SECRET") print(await p.stdout.read.aio()) await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const secret = modal.secrets.fromObject({ MY_SECRET: "hello" }); const image = modal.images.fromRegistry("python:3.13-slim"); const sb = await modal.sandboxes.create(app, image, { secrets: [secret], }); const p = await sb.exec(["bash", "-c", "echo $MY_SECRET"]); console.log(await p.stdout.readText()); sb.detach(); ``` {/snippet} {#snippet go()} ```go notest secret, err := mc.Secrets.FromMap(ctx, map[string]string{"MY_SECRET": "hello"}, nil) image := mc.Images.FromRegistry("python:3.13-slim", nil) sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Secrets: []*modal.Secret{secret}, }) defer sb.Detach() p, err := sb.Exec(ctx, []string{"bash", "-c", "echo $MY_SECRET"}, nil) stdout, err := io.ReadAll(p.Stdout) fmt.Println(string(stdout)) ``` {/snippet} ### OIDC identity tokens Pass `include_oidc_identity_token=True` to `modal.Sandbox.create` to inject a `MODAL_IDENTITY_TOKEN` environment variable containing an OIDC token into the Sandbox. See the [OIDC integration guide](https://modal.com/docs/guide/oidc-integration) for details. ### Custom Images Sandboxes support [custom images](https://modal.com/docs/guide/images) just as Functions do. These can be defined using [method chaining](https://modal.com/docs/guide/images) or by referencing an [existing Image in an external container registry](https://modal.com/docs/guide/existing-images). #### Separating Image builds from Sandbox creation To avoid blocking creation of new Sandboxes on rebuilding an invalidated Image, it's recommended to use Modal's named Images with sandboxes, rather than using inline Image definitions. Use [`Image.build`](https://modal.com/docs/sdk/py/latest/Image#build) to trigger Image builds as part of a deployment flow or at a regular interval (e.g., in a [scheduled job](https://modal.com/docs/guide/cron) or CI pipeline), then publish the result as a [named image](https://modal.com/docs/guide/named-images) Sandboxes can then use [`Image.from_name`](https://modal.com/docs/sdk/py/latest/Image#from_name) to reference the Image in a way that's guaranteed to not block on rebuilds. {#snippet python()} ```python notest # build_sandbox_image.py app = modal.App.lookup("sandbox-app", create_if_missing=True) # Method-chained image image = modal.Image.debian_slim().pip_install("pandas") # Or, for an external registry image with a fixed tag: # image = modal.Image.from_registry("ubuntu:24.04") with modal.enable_output(): image.build(app=app).publish("sandbox-runtime") # sandbox_app.py app = modal.App.lookup("sandbox-app", create_if_missing=True) image = modal.Image.from_name("sandbox-runtime") modal.Sandbox.create(app=app, image=image) ``` {/snippet} {#snippet python_async()} ```python notest # build_sandbox_image.py app = await modal.App.lookup.aio("sandbox-app", create_if_missing=True) # Method-chained image image = modal.Image.debian_slim().pip_install("pandas") # Or, for an external registry image with a fixed tag: # image = modal.Image.from_registry("ubuntu:24.04") with modal.enable_output(): await image.build.aio(app) await image.publish.aio("sandbox-runtime") # app.py app = await modal.App.lookup.aio("sandbox-app", create_if_missing=True) image = modal.Image.from_name("sandbox-runtime") await modal.Sandbox.create.aio(app=app, image=image) ``` {/snippet} {#snippet javascript()} ```javascript notest // build_sandbox_image.ts const app = await modal.apps.fromName("sandbox-app", { createIfMissing: true, }); const image = modal.images .fromRegistry("python:3.13-slim") .dockerfileCommands(["RUN pip install pandas"]); await image.build(app); await image.publish("sandbox-runtime"); // app.ts const app = await modal.apps.fromName("sandbox-app", { createIfMissing: true, }); const image = await modal.images.fromName("sandbox-runtime"); await modal.sandboxes.create(app, image); ``` {/snippet} {#snippet go()} ```go notest // build_sandbox_image.go app, err := mc.Apps.FromName(ctx, "sandbox-app", &modal.AppFromNameParams{ CreateIfMissing: true, }) image := mc.Images.FromRegistry("python:3.13-slim", nil). DockerfileCommands([]string{"RUN pip install pandas"}, nil) builtImage, err := image.Build(ctx, app, nil) err = builtImage.Publish(ctx, "sandbox-runtime", nil) // app.go app, err = mc.Apps.FromName(ctx, "sandbox-app", &modal.AppFromNameParams{ CreateIfMissing: true, }) image, err = mc.Images.FromName(ctx, "sandbox-runtime", nil) sb, err := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Detach() ``` {/snippet} - **Modal treats external Image tags as immutable once pulled.** For [external registry](https://modal.com/docs/guide/existing-images) images, `Image.build` always returns the cached version — Modal does not detect upstream changes to mutable tags like `:latest`. - To pick up a new version of an external registry image, update the tag in your deploy script (for example, `ubuntu:24.04` → `ubuntu:24.04-20240523`). #### Image build logs You may need to manually enable output streaming to see your image build logs: {#snippet python()} ```python fixture:sb_app image = modal.Image.debian_slim().pip_install("pandas", "numpy") with modal.enable_output(): sb = modal.Sandbox.create(image=image, app=sb_app) sb.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app image = modal.Image.debian_slim().pip_install("pandas", "numpy") with modal.enable_output(): sb = await modal.Sandbox.create.aio(image=image, app=sb_app) await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const image = modal.images .fromRegistry("python:3.13-slim") .dockerfileCommands(["RUN pip install pandas numpy"]); const sb = await modal.sandboxes.create(app, image); sb.detach(); ``` {/snippet} {#snippet go()} ```go notest image := mc.Images.FromRegistry("python:3.13-slim", nil). DockerfileCommands([]string{"RUN pip install pandas numpy"}, nil) // Note: Image build logs are automatically streamed in Go sb, err := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Detach() ``` {/snippet} ## Running a Sandbox with an entrypoint In most cases, Sandboxes are treated as a generic container that can run arbitrary commands. However, in some cases, you may want to run a single command or script as the entrypoint of the Sandbox. You can do this by passing command arguments to the Sandbox constructor: {#snippet python()} ```python fixture:sb_app sb = modal.Sandbox.create("python", "-m", "http.server", "8080", app=sb_app, timeout=10) for line in sb.stdout: print(line, end="") sb.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sb = await modal.Sandbox.create.aio("python", "-m", "http.server", "8080", app=sb_app, timeout=10) async for line in sb.stdout: print(line, end="") await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image, { command: ["python", "-m", "http.server", "8080"], timeoutMs: 10 * 1000, }); sb.detach(); ``` {/snippet} {#snippet go()} ```go notest sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"python", "-m", "http.server", "8080"}, Timeout: 10 * time.Second, }) sb.Detach() ``` {/snippet} This functionality is most useful for running long-lived services that you want to keep running in the background. See our [Jupyter notebook example](https://modal.com/docs/examples/jupyter_sandbox) for a more concrete example of this. ## Referencing Sandboxes from other code If you have a running Sandbox, you can retrieve it using the `from_id` method. {#snippet python()} ```python fixture:sb_app sb = modal.Sandbox.create(app=sb_app) sb_id = sb.object_id sb.detach() # ... later in the program ... sb2 = modal.Sandbox.from_id(sb_id) p = sb2.exec("echo", "hello") print(p.stdout.read()) sb2.terminate() sb2.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sb = await modal.Sandbox.create.aio(app=sb_app) sb_id = sb.object_id await sb.detach.aio() # ... later in the program ... sb2 = await modal.Sandbox.from_id.aio(sb_id) p = await sb2.exec.aio("echo", "hello") print(await p.stdout.read.aio()) await sb2.terminate.aio() await sb2.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image); const sbId = sb.sandboxId; await sb.detach(); // ... later in the program ... const sb2 = await modal.sandboxes.fromId(sbId); const p = await sb2.exec(["echo", "hello"]); console.log(await p.stdout.readText()); await sb2.terminate(); ``` {/snippet} {#snippet go()} ```go notest sb, err := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Detach() sbId := sb.SandboxID // ... later in the program ... sb2, err := mc.Sandboxes.FromID(ctx, sbId, nil) defer sb2.Terminate(ctx, nil) p, err := sb2.Exec(ctx, []string{"echo", "hello"}, nil) stdout, err := io.ReadAll(p.Stdout) fmt.Println(string(stdout)) ``` {/snippet} A common use case for this is keeping a pool of Sandboxes available for executing tasks as they come in. You can keep a list of `object_id`s of Sandboxes that are "open" and reuse them, closing over the `object_id` in whatever function is using them. ## Named Sandboxes You can assign a name to a Sandbox when creating it. Each name must be unique within an App - only one _running_ Sandbox can use a given name at a time. Note that the associated App must be a deployed App. Once a Sandbox completely stops running, its name becomes available for reuse. Some applications find Sandbox Names to be useful for ensuring that no more than one Sandbox is running per resource or project. If a Sandbox with the given name is already running, `create()` will raise an error. {#snippet python()} ```python notest sb1 = modal.Sandbox.create(app=sb_app, name="my-name") # This will raise a modal.exception.AlreadyExistsError. sb2 = modal.Sandbox.create(app=sb_app, name="my-name") ``` {/snippet} {#snippet python_async()} ```python notest sb1 = await modal.Sandbox.create.aio(app=sb_app, name="my-name") # This will raise a modal.exception.AlreadyExistsError. sb2 = await modal.Sandbox.create.aio(app=sb_app, name="my-name") ``` {/snippet} {#snippet javascript()} ```javascript notest const sb1 = await modal.sandboxes.create(app, image, { name: "my-name" }); // this will raise an AlreadyExistsError const sb2 = await modal.sandboxes.create(app, image, { name: "my-name" }); ``` {/snippet} {#snippet go()} ```go notest sb1, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Name: "my-name", }) // this will return an error sb2, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Name: "my-name", }) ``` {/snippet} A named Sandbox may be fetched from a deployed App using `from_name()` _but only if the Sandbox is currently running_. If no running Sandbox is found, `from_name()` will raise an error. {#snippet python()} ```python notest sb_app = modal.App.lookup("my-app", create_if_missing=True) sb1 = modal.Sandbox.create(app=sb_app, name="my-name") # Returns the currently running Sandbox with the name "my-name" from the # deployed App named "my-app". sb2 = modal.Sandbox.from_name("my-app", "my-name") assert sb1.object_id == sb2.object_id # sb1 and sb2 refer to the same Sandbox sb1.detach() sb2.detach() ``` {/snippet} {#snippet python_async()} ```python notest sb_app = await modal.App.lookup.aio("my-app", create_if_missing=True) sb1 = await modal.Sandbox.create.aio(app=sb_app, name="my-name") # Returns the currently running Sandbox with the name "my-name" from the # deployed App named "my-app". sb2 = await modal.Sandbox.from_name.aio("my-app", "my-name") assert sb1.object_id == sb2.object_id # sb1 and sb2 refer to the same Sandbox await sb1.detach.aio() await sb2.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const app = await modal.apps.fromName("my-app", { createIfMissing: true }); const sb1 = await modal.sandboxes.create(app, image, { name: "my-name" }); // returns the currently running Sandbox with the name "my-name" from the // deployed App named "my-app". const sb2 = await modal.sandboxes.fromName("my-app", "my-name"); console.assert(sb1.sandboxId === sb2.sandboxId); // sb1 and sb2 refer to the same Sandbox sb1.detach(); sb2.detach(); ``` {/snippet} {#snippet go()} ```go notest app, err := mc.Apps.FromName(ctx, "my-app", &modal.AppFromNameParams{ CreateIfMissing: true, }) sb1, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Name: "my-name", }) // returns the currently running Sandbox with the name "my-name" from the // deployed App named "my-app". sb2, err := mc.Sandboxes.FromName(ctx, "my-app", "my-name", nil) // sb1 and sb2 refer to the same Sandbox fmt.Println(sb1.SandboxID == sb2.SandboxID) defer sb1.Detach() defer sb2.Detach() ``` {/snippet} Sandbox Names may contain only alphanumeric characters, dashes, periods, and underscores, and must be shorter than 64 characters. ## Tagging Sandboxes can also be tagged with arbitrary key-value pairs. These tags can be used to filter results in `Sandbox.list`. {#snippet python()} ```python fixture:sb_app sandbox_v1_1 = modal.Sandbox.create("sleep", "10", app=sb_app) sandbox_v1_2 = modal.Sandbox.create("sleep", "20", app=sb_app) sandbox_v1_1.set_tags({"major_version": "1", "minor_version": "1"}) sandbox_v1_2.set_tags({"major_version": "1", "minor_version": "2"}) for sandbox in modal.Sandbox.list(app_id=sb_app.app_id): # All sandboxes. print(sandbox.object_id) for sandbox in modal.Sandbox.list( app_id=sb_app.app_id, tags={"major_version": "1"}, ): # Also all sandboxes. print(sandbox.object_id) for sandbox in modal.Sandbox.list( app_id=sb_app.app_id, tags={"major_version": "1", "minor_version": "2"}, ): # Just the latest sandbox. print(sandbox.object_id) sandbox_v1_1.detach() sandbox_v1_2.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sandbox_v1_1 = await modal.Sandbox.create.aio("sleep", "10", app=sb_app) sandbox_v1_2 = await modal.Sandbox.create.aio("sleep", "20", app=sb_app) await sandbox_v1_1.set_tags.aio({"major_version": "1", "minor_version": "1"}) await sandbox_v1_2.set_tags.aio({"major_version": "1", "minor_version": "2"}) async for sandbox in modal.Sandbox.list.aio(app_id=sb_app.app_id): # All sandboxes. print(sandbox.object_id) async for sandbox in modal.Sandbox.list.aio( app_id=sb_app.app_id, tags={"major_version": "1"}, ): # Also all sandboxes. print(sandbox.object_id) async for sandbox in modal.Sandbox.list.aio( app_id=sb_app.app_id, tags={"major_version": "1", "minor_version": "2"}, ): # Just the latest sandbox. print(sandbox.object_id) await sandbox_v1_1.detach.aio() await sandbox_v1_2.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const sandboxV1_1 = await modal.sandboxes.create(app, image, { command: ["sleep", "10"], }); const sandboxV1_2 = await modal.sandboxes.create(app, image, { command: ["sleep", "20"], }); await sandboxV1_1.setTags({ major_version: "1", minor_version: "1" }); await sandboxV1_2.setTags({ major_version: "1", minor_version: "2" }); // All sandboxes. for await (const sandbox of modal.sandboxes.list({ appId: app.appId })) { console.log(sandbox.sandboxId); } // Also all sandboxes. for await (const sandbox of modal.sandboxes.list({ appId: app.appId, tags: { major_version: "1" }, })) { console.log(sandbox.sandboxId); } // Just the latest sandbox. for await (const sandbox of modal.sandboxes.list({ appId: app.appId, tags: { major_version: "1", minor_version: "2" }, })) { console.log(sandbox.sandboxId); } sandboxV1_1.detach(); sandboxV1_2.detach(); ``` {/snippet} {#snippet go()} ```go notest sandboxV1_1, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"sleep", "10"}, }) sandboxV1_2, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"sleep", "20"}, }) defer sandboxV1_1.Detach() defer sandboxV1_2.Detach() sandboxV1_1.SetTags(ctx, map[string]string{"major_version": "1", "minor_version": "1"}) sandboxV1_2.SetTags(ctx, map[string]string{"major_version": "1", "minor_version": "2"}) // All sandboxes. it, _ := mc.Sandboxes.List(ctx, &modal.SandboxListParams{ AppID: app.AppID, }) for sandbox := range it { fmt.Println(sandbox.SandboxID) } // Also all sandboxes. it, _ = mc.Sandboxes.List(ctx, &modal.SandboxListParams{ AppID: app.AppID, Tags: map[string]string{"major_version": "1"}, }) for sandbox := range it { fmt.Println(sandbox.SandboxID) } // Just the latest sandbox. it, _ = mc.Sandboxes.List(ctx, &modal.SandboxListParams{ AppID: app.AppID, Tags: map[string]string{"major_version": "1", "minor_version": "2"}, }) for sandbox := range it { fmt.Println(sandbox.SandboxID) } ``` {/snippet} ## Cleaning up Client-side Connections Unlike other Modal objects, the local Sandbox will hold a direct connection to its compute substrate. While this connection should be automatically closed during garbage collection, we recommend explicitly cleaning up the resources once you are finished interacting with the Sandbox by calling its `detach()` method: {#snippet python()} ```python fixture:sb_app sb = modal.Sandbox.create(app=sb_app) sb.detach() ``` {/snippet} {#snippet python_async()} ```python fixture:sb_app sb = await modal.Sandbox.create.aio(app=sb_app) await sb.detach.aio() ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image); sb.detach(); ``` {/snippet} {#snippet go()} ```go notest sb, err := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Detach() ``` {/snippet} After calling `detach`, any operation using the Sandbox object is not guaranteed to work. If you want to continue interacting with a running Sandbox, use `Sandbox.from_id` to get a new Sandbox object that references the original Sandbox. In the Python SDK, `terminate` leaves your Sandbox attached, so we recommend calling `detach` after you are done with your terminated Sandbox. In the Go/JS SDK, `Terminate` will also detach your Sandbox. #### Running commands # Running commands in Sandboxes Once you have created a Sandbox, you can run commands inside it using the [`Sandbox.exec`](https://modal.com/docs/sdk/py/latest/Sandbox#exec) method. ```python notest sb = modal.Sandbox.create(app=my_app) process = sb.exec("echo", "hello", timeout=3) print(process.stdout.read()) process = sb.exec("python", "-c", "print(1 + 1)", timeout=3) print(process.stdout.read()) process = sb.exec( "bash", "-c", "for i in $(seq 1 10); do echo foo $i; sleep 0.1; done", timeout=5, ) for line in process.stdout: print(line, end="") sb.terminate() sb.detach() ``` `Sandbox.exec` returns a [`ContainerProcess`](https://modal.com/docs/sdk/py/latest/container_process#containerprocess) object, which allows access to the process's `stdout`, `stderr`, and `stdin`. The `timeout` parameter ensures that the `exec` command will run for at most `timeout` seconds. ## Input The Sandbox and ContainerProcess `stdin` handles are [`StreamWriter`](https://modal.com/docs/sdk/py/latest/io_streams#streamwriter) objects. This object supports flushing writes with both synchronous and asynchronous APIs: ```python notest import asyncio sb = modal.Sandbox.create(app=my_app) p = sb.exec("bash", "-c", "while read line; do echo $line; done") p.stdin.write(b"foo bar\n") p.stdin.write_eof() p.stdin.drain() p.wait() sb.terminate() sb.detach() async def run_async(): sb = await modal.Sandbox.create.aio(app=my_app) p = await sb.exec.aio("bash", "-c", "while read line; do echo $line; done") p.stdin.write(b"foo bar\n") p.stdin.write_eof() await p.stdin.drain.aio() await p.wait.aio() await sb.terminate.aio() await sb.detach.aio() asyncio.run(run_async()) ``` ## Output The Sandbox and ContainerProcess `stdout` and `stderr` handles are [`StreamReader`](https://modal.com/docs/sdk/py/latest/io_streams#streamreader) objects. These objects support reading from the stream in both synchronous and asynchronous manners. These handles also respect the timeout given to `Sandbox.exec`. To read from a stream after the underlying process has finished, you can use the `read` method, which blocks until the process finishes and returns the entire output stream. ```python notest sb = modal.Sandbox.create(app=my_app) p = sb.exec("echo", "hello") print(p.stdout.read()) sb.terminate() sb.detach() ``` To stream output, take advantage of the fact that `stdout` and `stderr` are iterable: ```python notest import asyncio sb = modal.Sandbox.create(app=my_app) p = sb.exec("bash", "-c", "for i in $(seq 1 10); do echo foo $i; sleep 0.1; done") for line in p.stdout: # Lines preserve the trailing newline character, so use end="" to avoid double newlines. print(line, end="") p.wait() sb.terminate() sb.detach() async def run_async(): sb = await modal.Sandbox.create.aio(app=my_app) p = await sb.exec.aio("bash", "-c", "for i in $(seq 1 10); do echo foo $i; sleep 0.1; done") async for line in p.stdout: # Avoid double newlines by using end="". print(line, end="") await p.wait.aio() await sb.terminate.aio() await sb.detach.aio() asyncio.run(run_async()) ``` ### Stream types By default, all streams are buffered in memory, waiting to be consumed by the client. You can control this behavior with the `stdout` and `stderr` parameters. These parameters are conceptually similar to the `stdout` and `stderr` parameters of the [`subprocess`](https://docs.python.org/3/library/subprocess.html#subprocess.DEVNULL) module. ```python notest from modal.stream_type import StreamType sb = modal.Sandbox.create(app=my_app) # Default behavior: buffered in memory. p = sb.exec( "bash", "-c", "echo foo; echo bar >&2", stdout=StreamType.PIPE, stderr=StreamType.PIPE, ) print(p.stdout.read()) print(p.stderr.read()) # Print the stream to STDOUT as it comes in. p = sb.exec( "bash", "-c", "echo foo; echo bar >&2", stdout=StreamType.STDOUT, stderr=StreamType.STDOUT, ) p.wait() # Discard all output. p = sb.exec( "bash", "-c", "echo foo; echo bar >&2", stdout=StreamType.DEVNULL, stderr=StreamType.DEVNULL, ) p.wait() sb.terminate() sb.detach() ``` #### Networking and security # Networking and security Sandboxes are built to be secure-by-default, meaning that a default Sandbox has no ability to accept incoming network connections or access your Modal resources. ## Outbound access control By default, Sandboxes can make outbound connections to any public IP address. Modal provides three levels of outbound network restriction: | Level | Parameter | What it controls | | ----------------------------- | --------------------------- | -------------------------------------------------------------- | | **Full block** | `block_network=True` | Drops all outbound traffic. | | **IP-range allowlist** | `outbound_cidr_allowlist` | Only allows traffic to the listed CIDR ranges (any protocol). | | **Domain allowlist** _(Beta)_ | `outbound_domain_allowlist` | Only allows TLS traffic (port 443) to the listed domain names. | `outbound_cidr_allowlist` and `outbound_domain_allowlist` can be combined additively - traffic that meets either criteria will be let through. For advanced HTTPS inspection, the experimental `proxy_traffic_via_sidecar` option routes outbound TCP traffic on port 443 from the main container through a Sidecar. Relaying replaces the Sandbox's own controls on that traffic rather than adding to them: an `outbound_cidr_allowlist` continues to govern every other port, but stops applying to port 443, which is instead governed by the Sidecar's egress controls. See [Routing HTTPS traffic through a Sidecar](https://modal.com/docs/guide/sandbox-sidecars#routing-https-traffic-through-a-sidecar) for details. ### Blocking all network access Set `block_network=True` to prevent the Sandbox from making any outbound connections: {#snippet python()} ```python notest sb = modal.Sandbox.create( "python", "my_script.py", block_network=True, app=app, ) ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image, { command: ["python", "my_script.py"], blockNetwork: true, }); ``` {/snippet} {#snippet go()} ```go notest sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"python", "my_script.py"}, BlockNetwork: true, }) ``` {/snippet} When `block_network` is enabled, `outbound_cidr_allowlist`, `outbound_domain_allowlist`, and `inbound_cidr_allowlist` cannot be used. ### Restricting by IP range (CIDR allowlist) Use `outbound_cidr_allowlist` to restrict outbound traffic to a set of IP ranges. All traffic to IPs outside these ranges (except traffic allowed by `outbound_domain_allowlist`) is blocked. {#snippet python()} ```python notest sb = modal.Sandbox.create( "sleep", "infinity", outbound_cidr_allowlist=["52.0.0.0/8", "10.0.1.0/24"], app=app, ) ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image, { command: ["sleep", "infinity"], outboundCidrAllowlist: ["52.0.0.0/8", "10.0.1.0/24"], }); ``` {/snippet} {#snippet go()} ```go notest sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"sleep", "infinity"}, OutboundCIDRAllowlist: &modal.Allowlist{Entries: []string{"52.0.0.0/8", "10.0.1.0/24"}}, }) ``` {/snippet} ### Restricting by domain name (domain allowlist) Use `outbound_domain_allowlist` to restrict outbound TLS traffic to a set of domain names: {#snippet python()} ```python notest sb = modal.Sandbox.create( "sleep", "infinity", outbound_domain_allowlist=["api.openai.com", "*.github.com"], app=app, ) ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image, { command: ["sleep", "infinity"], outboundDomainAllowlist: ["api.openai.com", "*.github.com"], }); ``` {/snippet} {#snippet go()} ```go notest sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"sleep", "infinity"}, OutboundDomainAllowlist: &modal.Allowlist{Entries: []string{"api.openai.com", "*.github.com"}}, }) ``` {/snippet} When a domain allowlist is set: - **TLS (port 443)** connections are allowed only to the listed domains, or to IPs on a CIDR allowlist. Other connections are blocked and logged to the Sandbox's system output stream. - **Non-TLS traffic** (HTTP, raw TCP, UDP) to IPs that are not on a CIDR allowlist is **blocked**. Entries prefixed with `*.` match the parent domain and any subdomain: | Allowlist entry | Matches | Does not match | | --------------- | ------------------------------------------------- | ----------------- | | `example.com` | `example.com` | `sub.example.com` | | `*.example.com` | `example.com`, `a.example.com`, `a.b.example.com` | `evilexample.com` | #### How domain filtering works Domains are matched against the [SNI](https://en.wikipedia.org/wiki/Server_Name_Indication) in the TLS handshake, and Modal resolves that hostname itself rather than trusting the destination IP the Sandbox picked. TLS traffic is not decrypted, so the `Host` header, URL path, and body are never inspected. Encrypted Client Hello (ECH) is not supported. Modal only sees the outer public name, not the real hostname inside it, so an ECH connection is matched against that public name and is blocked unless the public name is on the allowlist. Two domains can share a TLS endpoint, such as two tenants of the same CDN. A Sandbox can reach a non-allowlisted domain there by sending an allowlisted SNI with the other name in the `Host` header, a technique called _domain fronting_. Many providers reject mismatched requests, but the allowlist itself does not prevent the mismatch. ### Updating the network policy at runtime This API is experimental and has [limitations](#dynamic-policy-limitations) that will be removed in a future release. You can replace the outbound network policy of a running Sandbox without restarting it. This is useful when an agent's trust level changes mid-session — for example, starting with broad access while installing dependencies and then locking down to only the domains a tool needs. {#snippet python()} ```python notest # Start with all outbound traffic allowed. sb = modal.Sandbox.create( "sleep", "infinity", outbound_domain_allowlist=["*"], outbound_cidr_allowlist=["0.0.0.0/0"], app=app, ) # ... later, narrow the policy to only the domains we need. sb._experimental_set_outbound_network_policy( outbound_domain_allowlist=["api.openai.com", "*.github.com"], ) # Or block all outbound traffic by passing empty allowlists. sb._experimental_set_outbound_network_policy( outbound_domain_allowlist=[], outbound_cidr_allowlist=[], ) # Widen back to allow-all when needed. sb._experimental_set_outbound_network_policy( outbound_domain_allowlist=["*"], outbound_cidr_allowlist=["0.0.0.0/0"], ) ``` {/snippet} {#snippet javascript()} ```javascript notest // Start with all outbound traffic allowed. const sb = await modal.sandboxes.create(app, image, { command: ["sleep", "infinity"], outboundDomainAllowlist: ["*"], outboundCidrAllowlist: ["0.0.0.0/0"], }); // ... later, narrow the policy to only the domains we need. await sb.updateNetworkPolicy({ outboundDomainAllowlist: ["api.openai.com", "*.github.com"], outboundCidrAllowlist: [], }); // Or block all outbound traffic by passing empty allowlists. await sb.updateNetworkPolicy({ outboundDomainAllowlist: [], outboundCidrAllowlist: [], }); // Widen back to allow-all when needed. await sb.updateNetworkPolicy({ outboundDomainAllowlist: ["*"], outboundCidrAllowlist: ["0.0.0.0/0"], }); ``` {/snippet} {#snippet go()} ```go notest // Start with all outbound traffic allowed. sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"sleep", "infinity"}, OutboundDomainAllowlist: &modal.Allowlist{Entries: []string{"*"}}, OutboundCIDRAllowlist: &modal.Allowlist{Entries: []string{"0.0.0.0/0"}}, }) // ... later, narrow the policy to only the domains we need. err = sb.UpdateNetworkPolicy(ctx, &modal.SandboxUpdateNetworkPolicyParams{ OutboundDomainAllowlist: &modal.Allowlist{Entries: []string{"api.openai.com", "*.github.com"}}, OutboundCIDRAllowlist: &modal.Allowlist{Entries: []string{}}, }) // Or block all outbound traffic by passing empty allowlists. err = sb.UpdateNetworkPolicy(ctx, &modal.SandboxUpdateNetworkPolicyParams{ OutboundDomainAllowlist: &modal.Allowlist{Entries: []string{}}, OutboundCIDRAllowlist: &modal.Allowlist{Entries: []string{}}, }) // Widen back to allow-all when needed. err = sb.UpdateNetworkPolicy(ctx, &modal.SandboxUpdateNetworkPolicyParams{ OutboundDomainAllowlist: &modal.Allowlist{Entries: []string{"*"}}, OutboundCIDRAllowlist: &modal.Allowlist{Entries: []string{"0.0.0.0/0"}}, }) ``` {/snippet} The new policy takes effect immediately. Established connections that the new policy no longer permits are terminated. #### Dynamic policy limitations - Each allowlist type must be set at creation time to be usable later. To update `outbound_domain_allowlist` at runtime, the Sandbox must be created with `outbound_domain_allowlist` (e.g. `["*"]`). The same applies to `outbound_cidr_allowlist` — create with `["0.0.0.0/0"]` if you want to restrict by CIDR later. - `block_network=True` is not compatible with this API. Use empty allowlists (`[]`) to block all traffic instead. ## Inbound access control Use `inbound_cidr_allowlist` to restrict which IP addresses can connect **inbound** to the Sandbox through tunnels and Sandbox Connect Tokens: {#snippet python()} ```python notest sb = modal.Sandbox.create( "python", "-m", "http.server", "8080", encrypted_ports=[8080], inbound_cidr_allowlist=["203.0.113.0/24"], app=app, ) ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image, { command: ["python", "-m", "http.server", "8080"], encryptedPorts: [8080], inboundCidrAllowlist: ["203.0.113.0/24"], }); ``` {/snippet} {#snippet go()} ```go notest sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"python", "-m", "http.server", "8080"}, EncryptedPorts: []int{8080}, InboundCIDRAllowlist: []string{"203.0.113.0/24"}, }) ``` {/snippet} ## Connecting to Sandboxes with HTTP and WebSockets You can make authenticated HTTP and WebSocket requests to a Sandbox by generating Sandbox Connect Tokens. They work like this: {#snippet python()} ```python notest # Start a Sandbox with a server running on port 8080. sb = modal.Sandbox.create( "bash", "-c", "python3 -m http.server 8080", app=my_app, ) # Create a connect token, optionally including arbitrary user metadata. # Port 8080 is the default and could be omitted here. creds = sb.create_connect_token(user_metadata={"user_id": "foo"}, port=8080) # Make an HTTP request, passing the token in the Authorization header. requests.get(creds.url, headers={"Authorization": f"Bearer {creds.token}"}) # You can also put the token in a `_modal_connect_token` query param. url = f"{creds.url}/?_modal_connect_token={creds.token}" ws_url = url.replace("https://", "wss://") with websockets.connect(ws_url) as socket: socket.send("Hello world!") sb.detach() ``` {/snippet} {#snippet javascript()} ```javascript notest // Start a Sandbox with a server running on port 8080. const sb = await modal.sandboxes.create(app, image, { command: ["bash", "-c", "python3 -m http.server 8080"], }); // Create a connect token, optionally including arbitrary user metadata. // Port 8080 is the default and could be omitted here. const creds = await sb.createConnectToken({ userMetadata: '{"user_id": "foo"}', port: 8080, }); // Make an HTTP request, passing the token in the Authorization header. const response = await fetch(creds.url, { headers: { Authorization: `Bearer ${creds.token}` }, }); sb.detach(); ``` {/snippet} {#snippet go()} ```go notest // Start a Sandbox with a server running on port 8080. sb, err := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"bash", "-c", "python3 -m http.server 8080"}, }) // Create a connect token, optionally including arbitrary user metadata. // Port 8080 is the default and could be omitted here. creds, err := sb.CreateConnectToken(ctx, &modal.SandboxCreateConnectTokenParams{ UserMetadata: `{"user_id": "foo"}`, Port: 8080, }) // Make an HTTP request, passing the token in the Authorization header. req, _ := http.NewRequestWithContext(ctx, "GET", creds.URL, nil) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", creds.Token)) resp, _ := http.DefaultClient.Do(req) sb.Detach() ``` {/snippet} The server running on the specified port in the container will receive an authenticated request with an unspoofable `X-Verified-User-Data` header whose value is the JSON-serialized metadata that was passed as `user_metadata` to `create_connect_token()`. This can be used by the application to determine access control, for example. There are a few things to remember with Sandbox Connect Tokens: 1. By default, requests are routed to port 8080 in the container. Pass `port` to `create_connect_token()` to route to a different port. 2. The token may be sent in an `Authorization` header, in a `_modal_connect_token` query param, or in a `_modal_connect_token` cookie. 3. If `_modal_connect_token` is set as a query param, the resulting response will include a `Set-Cookie` header that sets it as a cookie. 4. The `user_metadata` must be JSON-serializable and must be less than 512 characters after serialization. 5. The `user_metadata` is encoded into the connect token itself, so it should not contain secrets. ### Forwarding ports While it is recommended to use [Sandbox Connect Tokens](#connecting-to-sandboxes-with-http-and-websockets) for HTTP requests and WebSocket connections to the container, you can also expose raw TCP ports to the internet. This is useful if, for example, you want to run a server inside the Sandbox that expects a raw TCP connection and handles authentication itself. Use the `encrypted_ports` and `unencrypted_ports` parameters of `Sandbox.create` to specify which ports to forward. You can then access the public URL of a tunnel using the [`Sandbox.tunnels`](https://modal.com/docs/sdk/py/latest/Sandbox#tunnels) method: ```python notest import requests import time sb = modal.Sandbox.create( "python", "-m", "http.server", "12345", encrypted_ports=[12345], app=my_app, ) tunnel = sb.tunnels()[12345] time.sleep(1) # Wait for server to start. print(f"Connecting to {tunnel.url}...") print(requests.get(tunnel.url, timeout=5).text) sb.detach() ``` It is also possible to create an encrypted port that uses `HTTP/2` rather than `HTTP/1.1` with the `h2_ports` option. This will return a URL that you can make H2 (HTTP/2 + TLS) requests to. If you want to run an `HTTP/2` server inside a sandbox, this feature may be useful. Here is an example: ```python notest import time port = 4359 sb = modal.Sandbox.create( app=my_app, image=my_image, h2_ports=[port], ) p = sb.exec("python", "my_http2_server.py") tunnel = sb.tunnels()[port] time.sleep(1) print(f"Tunnel URL: {tunnel.url}") sb.detach() ``` For more details on how tunnels work, see the [tunnels guide](https://modal.com/docs/guide/tunnels). ### Custom domains Custom domains for Sandbox tunnels are available on the Team and Enterprise plans. Visit workspace settings to upgrade. The infrastructure is production-grade, but onboarding requires a manual setup step. By default, Sandbox tunnels are served from subdomains of `w.modal.host`. In some cases, it's necessary to have a tunnel served through a custom domain for security reasons. This is possible with manual setup. Note that tunnel custom domains are distinct from other custom domains in Modal. Other custom domains use `CNAME` forwarding. For tunnels, we need to use an `NS` record to delegate the domain to Modal's nameservers. **1. Delegate a (sub)domain to Modal's nameservers.** Add `NS` records to your DNS zone pointing to Modal's nameservers. For example, to use `sandbox.example.com`, add the following records in your DNS provider's control panel: | Name | Type | Value | | --------------------- | ---- | -------------------- | | `sandbox.example.com` | NS | `w-ns-a.modal.host.` | | `sandbox.example.com` | NS | `w-ns-b.modal.host.` | | `sandbox.example.com` | NS | `w-ns-c.modal.host.` | | `sandbox.example.com` | NS | `w-ns-d.modal.host.` | You can delegate any subdomain depth you like (e.g. `tunnels.a.b.c.example.com`). **2. Ask Modal to set up the domain.** Reach out to us on Slack and provide the domain name. We'll enable it for your workspace. **3. Pass `custom_domain` to `Sandbox.create`.** ```python notest import modal app = modal.App.lookup("my-app", create_if_missing=True) sb = modal.Sandbox.create( "python", "-m", "http.server", "8080", encrypted_ports=[8080], custom_domain="sandbox.example.com", app=app, ) tunnel = sb.tunnels()[8080] print(tunnel.url) # https://[...].sandbox.example.com ``` Modal will provision a TLS certificate automatically. Sandbox Connect Tokens generated for this sandbox will also use the custom domain. ## Security model Sandboxes are built on top of [gVisor](https://gvisor.dev/), a container runtime by Google that provides strong isolation properties. gVisor has custom logic to prevent Sandboxes from making malicious system calls, giving you stronger isolation than most other container runtimes. Additionally, Sandboxes are not authorized to access other resources in your Modal workspace the way that Modal Functions are [by default](https://modal.com/docs/guide/restricted-access). As a result, the blast radius of any malicious code will be limited to the Sandbox container itself. #### File access # Filesystem Access There are multiple options for uploading files to a Sandbox and accessing them from outside the Sandbox. ## Filesystem API The most convenient way to pass data in and out of the Sandbox during execution is to use our filesystem API: {#snippet python()} ```python import modal app = modal.App.lookup("sandbox-fs-demo", create_if_missing=True) sb = modal.Sandbox.create(app=app) # Write text to a file in the Sandbox. sb.filesystem.write_text("Hello World!\n", "/tmp/test.txt") # Read the file back from the Sandbox into a string. contents = sb.filesystem.read_text("/tmp/test.txt") print(contents) sb.terminate() sb.detach() ``` {/snippet} {#snippet javascript()} ```javascript notest const modal = new ModalClient(); const app = await modal.apps.fromName("sandbox-fs-demo", { createIfMissing: true, }); const image = modal.images.fromRegistry("python:3.13-slim"); const sb = await modal.sandboxes.create(app, image); // Write text to a file in the Sandbox. await sb.filesystem.writeText("Hello World!\n", "/tmp/test.txt"); // Read the file back from the Sandbox into a string. const contents = await sb.filesystem.readText("/tmp/test.txt"); console.log(contents); await sb.terminate(); ``` {/snippet} {#snippet go()} ```go notest package main import ( "context" "fmt" modal "github.com/modal-labs/modal-client/go" ) func main() { ctx := context.Background() mc, _ := modal.NewClient() app, _ := mc.Apps.FromName(ctx, "sandbox-fs-demo", &modal.AppFromNameParams{ CreateIfMissing: true, }) image := mc.Images.FromRegistry("python:3.13-slim", nil) sb, _ := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Terminate(ctx, nil) fs := sb.Filesystem // Write text to a file in the Sandbox. fs.WriteText(ctx, "Hello World!\n", "/tmp/test.txt", nil) // Read the file back from the Sandbox into a string. contents, _ := fs.ReadText(ctx, "/tmp/test.txt", nil) fmt.Println(contents) } ``` {/snippet} It has convenience APIs for streaming file copies in both directions: {#snippet python()} ```python from pathlib import Path import modal # Write a local file. with open("local-file.txt", "w") as f: f.write("Hello World!\n") app = modal.App.lookup("sandbox-fs-demo", create_if_missing=True) sb = modal.Sandbox.create(app=app) # Copy the local file into the Sandbox. sb.filesystem.copy_from_local("local-file.txt", "/tmp/file-in-sandbox.txt") # Copy it back to the local filesystem. sb.filesystem.copy_to_local("/tmp/file-in-sandbox.txt", "local-file-copy.txt") print(Path("local-file-copy.txt").read_text()) sb.terminate() sb.detach() ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image); // Write a local file. await writeFile("local-file.txt", "Hello World!\n", "utf-8"); // Copy the local file into the Sandbox. await sb.filesystem.copyFromLocal("local-file.txt", "/tmp/file-in-sandbox.txt"); // Copy it back to the local filesystem. await sb.filesystem.copyToLocal( "/tmp/file-in-sandbox.txt", "local-file-copy.txt", ); console.log(await readFile("local-file-copy.txt", "utf-8")); await sb.terminate(); ``` {/snippet} {#snippet go()} ```go notest sb, _ := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Terminate(ctx, nil) fs := sb.Filesystem // Write a local file. os.WriteFile("local-file.txt", []byte("Hello World!\n"), 0o644) // Copy the local file into the Sandbox. fs.CopyFromLocal(ctx, "local-file.txt", "/tmp/file-in-sandbox.txt", nil) // Copy it back to the local filesystem. fs.CopyToLocal(ctx, "/tmp/file-in-sandbox.txt", "local-file-copy.txt", nil) data, _ := os.ReadFile("local-file-copy.txt") fmt.Println(string(data)) ``` {/snippet} It also offers APIs for inspecting and managing files: {#snippet python()} ```python import modal app = modal.App.lookup("sandbox-fs-demo", create_if_missing=True) sb = modal.Sandbox.create(app=app) # Set up a structured project. sb.filesystem.make_directory("/tmp/project/results") # Let the Sandbox do some work and write outputs to files. sb.filesystem.write_text("42\n", "/tmp/project/results/answer.txt") sb.filesystem.write_text("debug info\n", "/tmp/project/results/debug.log") # Inspect what was produced. for entry in sb.filesystem.list_files("/tmp/project/results"): print(entry.name, entry.type.value, entry.size) # Check that the result file has content before downloading it. info = sb.filesystem.stat("/tmp/project/results/answer.txt") if info.size > 0: answer = sb.filesystem.read_text("/tmp/project/results/answer.txt") print(answer) # Clean up the whole project. sb.filesystem.remove("/tmp/project", recursive=True) sb.terminate() sb.detach() ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image); // Set up a structured project. await sb.filesystem.makeDirectory("/tmp/project/results"); // Let the Sandbox do some work and write outputs to files. await sb.filesystem.writeText("42\n", "/tmp/project/results/answer.txt"); await sb.filesystem.writeText("debug info\n", "/tmp/project/results/debug.log"); // Inspect what was produced. const entries = await sb.filesystem.listFiles("/tmp/project/results"); for (const entry of entries) { console.log(entry.name, entry.type, entry.size); } // Check that the result file has content before downloading it. const info = await sb.filesystem.stat("/tmp/project/results/answer.txt"); if (info.size > 0) { const answer = await sb.filesystem.readText( "/tmp/project/results/answer.txt", ); console.log(answer); } // Clean up the whole project. await sb.filesystem.remove("/tmp/project", { recursive: true }); await sb.terminate(); ``` {/snippet} {#snippet go()} ```go notest sb, _ := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Terminate(ctx, nil) fs := sb.Filesystem // Set up a structured project. fs.MakeDirectory(ctx, "/tmp/project/results", nil) // Let the Sandbox do some work and write outputs to files. fs.WriteText(ctx, "42\n", "/tmp/project/results/answer.txt", nil) fs.WriteText(ctx, "debug info\n", "/tmp/project/results/debug.log", nil) // Inspect what was produced. entries, _ := fs.ListFiles(ctx, "/tmp/project/results", nil) for _, entry := range entries { fmt.Println(entry.Name, entry.Type, entry.Size) } // Check that the result file has content before downloading it. info, _ := fs.Stat(ctx, "/tmp/project/results/answer.txt", nil) if info.Size > 0 { answer, _ := fs.ReadText(ctx, "/tmp/project/results/answer.txt", nil) fmt.Println(answer) } // Clean up the whole project. fs.Remove(ctx, "/tmp/project", &modal.SandboxFilesystemRemoveParams{Recursive: true}) ``` {/snippet} These APIs may be used to read files of up to 5GB and write files of any size. However, if you have a large dataset that you want to use repeatedly from many sandboxes, consider [using Volumes](#using-volumes). Sandbox filesystem access was previously exposed through methods on the `Sandbox` and a `FileIO` object. This API is now deprecated; consult our [migration guide](https://modal.com/docs/guide/migrate-sandbox-filesystem) to update any code still using the legacy API. ## Using Volumes It's possible to use Modal [Volume](https://modal.com/docs/sdk/py/latest/Volume)s or [CloudBucketMount](https://modal.com/docs/guide/cloud-bucket-mounts)s with Sandboxes. Volumes and CloudBucketMounts allow you to upload data once and access that data efficiently from many sandboxes. To access a Volume from a Sandbox, you can use the `volumes` parameter of `Sandbox.create`: ```python notest # Find or create a Volume with the name "my-volume". vol = modal.Volume.from_name("my-volume", create_if_missing=True) sb = modal.Sandbox.create( volumes={"/cache": vol}, app=my_app, ) # Read a file in the Volume. p = sb.exec("bash", "-c", "cat /cache/some-file.txt") print(p.stdout.read()) p.wait() # Write a file to the Volume. p = sb.exec("bash", "-c", "echo foo > /cache/a.txt") p.wait() sb.terminate(wait=True) sb.detach() # Access the Volume file from outside the Sandbox. for data in vol.read_file("a.txt"): print(data) ``` File syncing behavior differs between Volumes and CloudBucketMounts. For Volumes, changes are persisted by [background commits](https://modal.com/docs/guide/volumes#background-commits) that run every few seconds while the Sandbox executes, with a final commit when the Sandbox terminates. With Volumes v2, you can also commit explicitly at any point (see [Committing Volume changes with `sync`](#committing-volume-changes-with-sync-v2-only) below). For CloudBucketMounts, files are synced automatically. You need to explicitly reload a Volume to see changes made since it was first mounted, by invoking the [.reload_volumes()](https://modal.com/docs/sdk/py/latest/Sandbox#reload_volumes) method on the sandbox object. ### Mounting a subdirectory You can mount a subdirectory of a Volume instead of the entire Volume using [`with_mount_options`](https://modal.com/docs/guide/volumes#mount-options). This is especially useful when many Sandboxes share a single Volume but each Sandbox should only access its own data: {#snippet python()} ```python notest sb_app = modal.App.lookup("my-app", create_if_missing=True) vol = modal.Volume.from_name("shared-volume", create_if_missing=True) # Each Sandbox only sees its own subdirectory of the Volume. sb = modal.Sandbox.create( volumes={"/data": vol.with_mount_options(sub_path="/users/user_123")}, app=sb_app, ) # /data inside the Sandbox maps to /users/user_123 in the Volume. # The Sandbox cannot see or modify files belonging to other users. p = sb.exec("bash", "-c", "echo hello > /data/output.txt") p.wait() sb.terminate(wait=True) sb.detach() ``` {/snippet} {#snippet javascript()} ```javascript notest const app = await modal.apps.fromName("my-app", { createIfMissing: true, }); const vol = await modal.volumes.fromName("shared-volume", { createIfMissing: true, }); const image = modal.images.fromRegistry("python:3.13-slim"); // Each Sandbox only sees its own subdirectory of the Volume. const sb = await modal.sandboxes.create(app, image, { volumes: { "/data": vol.withMountOptions({ subPath: "/users/user_123" }) }, }); // /data inside the Sandbox maps to /users/user_123 in the Volume. // The Sandbox cannot see or modify files belonging to other users. const p = await sb.exec(["bash", "-c", "echo hello > /data/output.txt"]); await p.wait(); await sb.terminate({ wait: true }); ``` {/snippet} {#snippet go()} ```go notest app, _ := mc.Apps.FromName(ctx, "volume-subdir-test", &modal.AppFromNameParams{CreateIfMissing: true}) vol, _ := mc.Volumes.FromName(ctx, "shared-volume", &modal.VolumeFromNameParams{ CreateIfMissing: true, }) image := mc.Images.FromRegistry("python:3.13-slim", nil) // Each Sandbox only sees its own subdirectory of the Volume. subPath := "/users/user_123" sb, _ := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Volumes: map[string]*modal.Volume{ "/data": vol.WithMountOptions(&modal.VolumeMountOptions{SubPath: &subPath}), }, }) defer sb.Terminate(ctx, nil) // /data inside the Sandbox maps to /users/user_123 in the Volume. // The Sandbox cannot see or modify files belonging to other users. p, _ := sb.Exec(ctx, []string{"bash", "-c", "echo hello > /data/output.txt"}, nil) p.Wait(ctx) ``` {/snippet} For more details on Volume mount options, see the [Volumes guide](https://modal.com/docs/guide/volumes#mount-options). ### Committing Volume changes with `sync` (v2 only) For [Volumes v2](https://modal.com/docs/guide/volumes#volumes-v2-overview), you can explicitly commit changes at any point during Sandbox execution by running the `sync` command on the mountpoint. This persists all data and metadata changes to the Volume's storage without waiting for the Sandbox to terminate: ```python notest sb = modal.Sandbox.create( volumes={"/data": modal.Volume.from_name("my-v2-volume")}, app=my_app, ) # Write files to the volume sb.exec("bash", "-c", "echo 'hello' > /data/output.txt").wait() # Commit changes immediately p = sb.exec("sync", "/data") p.wait() if p.returncode != 0: raise Exception(f"sync failed with exit code {p.returncode}") # Changes are now persisted and visible to other containers sb.terminate() sb.detach() ``` This is particularly useful for long-running Sandboxes where you want to persist intermediate results, or when you need changes to be visible to other containers before the Sandbox terminates. ## Adding files to an Image In some cases, you may want to [add a file to an Image itself](https://modal.com/docs/guide/images#add-local-files-with-add_local_dir-and-add_local_file). This is useful if the file will be used by many Sandboxes, or if you want to access that file from the Sandbox's entrypoint command. This can be done using the [`add_local_file`](https://modal.com/docs/sdk/py/latest/Image#add_local_file) and [`add_local_dir`](https://modal.com/docs/sdk/py/latest/Image#add_local_dir) methods on the [`Image`](https://modal.com/docs/sdk/py/latest/Image) class: ```python notest # Eagerly build the image - otherwise the Image will lazily build when the # Sandbox is created. image = ( modal.Image.debian_slim() .add_local_dir( local_path="/home/user/my_dir", remote_path="/app", ) .build(my_app) ) sb = modal.Sandbox.create(app=my_app, image=image) p = sb.exec("ls", "/app") print(p.stdout.read()) p.wait() sb.detach() ``` #### Snapshots # Snapshots Sandboxes support snapshotting, allowing you to save your Sandbox's state and restore it later. This is useful for: - Reducing startup latency - Creating custom environments for your Sandboxes to run in - Backing up your Sandbox's state for debugging - Running large-scale experiments with the same initial state - Branching your Sandbox's state to test different code changes independently Modal currently supports three different kinds of Sandbox snapshots: 1. [Filesystem Snapshots](#filesystem-snapshots) 2. [Directory Snapshots](#directory-snapshots) 3. [Memory Snapshots](#memory-snapshots) ## Snapshot Retention Different snapshot types have different retention policies: | Snapshot Type | Default Retention Period | | ------------------- | ------------------------ | | Filesystem Snapshot | 30 days after creation | | Directory Snapshot | 30 days after creation | | Memory Snapshot | 7 days after creation | **Breaking change in v1.5 (Python) / v0.8.0 (Go/JS):** Filesystem Snapshots now default to a 30-day TTL. Previously, Filesystem Snapshots persisted indefinitely and Directory Snapshots already defaulted to 30 days. Both `snapshot_filesystem()` and `snapshot_directory()` now accept an explicit TTL parameter that you can use to override the default, including opting out of expiry entirely. Filesystem Snapshots and Directory Snapshots are [Images](https://modal.com/docs/sdk/py/latest/Image) and are automatically garbage collected after their TTL expires (30 days by default). You can configure a custom TTL when creating a snapshot, or opt out of expiry entirely to retain snapshots indefinitely. Memory Snapshots expire 7 days after creation and cannot currently be extended. Here is how to configure custom TTLs for each snapshot type: {#snippet python()} ```python notest # Filesystem snapshot with custom TTL of 7 days image = sb.snapshot_filesystem(ttl=7 * 24 * 3600) # Filesystem snapshot with no expiry (retain indefinitely, like the pre-v1.5 default) image = sb.snapshot_filesystem(ttl=None) # Directory snapshot with custom TTL of 7 days snapshot = sb.snapshot_directory("/project", ttl=7 * 24 * 3600) # Directory snapshot with no expiry snapshot = sb.snapshot_directory("/project", ttl=None) ``` {/snippet} {#snippet javascript()} ```javascript notest // Filesystem snapshot with custom TTL of 7 days let image = await sb.snapshotFilesystem({ ttlMs: 7 * 24 * 3600 * 1000 }); // Filesystem snapshot with no expiry (retain indefinitely, like the pre-v0.8.0 default) image = await sb.snapshotFilesystem({ ttlMs: null }); // Directory snapshot with custom TTL of 7 days let snapshot = await sb.snapshotDirectory("/project", { ttlMs: 7 * 24 * 3600 * 1000, }); // Directory snapshot with no expiry snapshot = await sb.snapshotDirectory("/project", { ttlMs: null }); ``` {/snippet} {#snippet go()} ```go notest // Filesystem snapshot with custom TTL of 7 days image, _ := sb.SnapshotFilesystem(ctx, &modal.SandboxSnapshotFilesystemParams{ TTL: 7 * 24 * time.Hour, }) // Filesystem snapshot with no expiry (retain indefinitely, like the pre-v0.8.0 default) image, _ = sb.SnapshotFilesystem(ctx, &modal.SandboxSnapshotFilesystemParams{ TTL: modal.NoExpiryTTL, }) // Directory snapshot with custom TTL of 7 days snapshot, _ := sb.SnapshotDirectory(ctx, "/project", &modal.SandboxSnapshotDirectoryParams{ TTL: 7 * 24 * time.Hour, }) // Directory snapshot with no expiry snapshot, _ = sb.SnapshotDirectory(ctx, "/project", &modal.SandboxSnapshotDirectoryParams{ TTL: modal.NoExpiryTTL, }) ``` {/snippet} If you try to use an expired snapshot, Modal will raise a `NotFoundError` — immediately when mounting the Image into a running Sandbox, or upon first interaction (e.g. `exec` or `wait`) when starting a new Sandbox from the expired Image. Note that `Image.from_id()` is itself lazy and will not raise an error on construction even if the provided Image ID has been deleted. To manage storage for long-lived snapshots, you can delete them programmatically when no longer needed. See [Deleting Snapshots](#deleting-snapshots) for details. ## Filesystem Snapshots Filesystem Snapshots are copies of the Sandbox's filesystem at a given point in time. These Snapshots are [Images](https://modal.com/docs/sdk/py/latest/Image) and can be used to create new Sandboxes. To create a Filesystem Snapshot, you can use the [`Sandbox.snapshot_filesystem()`](https://modal.com/docs/sdk/py/latest/Sandbox#snapshot_filesystem) method: ```python notest import modal app = modal.App.lookup("sandbox-fs-snapshot-test", create_if_missing=True) sb = modal.Sandbox.create(app=app) p = sb.exec("bash", "-c", "echo 'test' > /test") p.wait() assert p.returncode == 0, "failed to write to file" image = sb.snapshot_filesystem() sb.terminate() sb2 = modal.Sandbox.create(image=image, app=app) p2 = sb2.exec("bash", "-c", "cat /test") assert p2.stdout.read().strip() == "test" ``` Filesystem Snapshots are optimized for performance: they are calculated as the difference from your base image, so only modified files are stored. Restoring a Filesystem Snapshot utilizes the same infrastructure we use to get fast cold starts for your Sandboxes. See [Snapshot Retention](#snapshot-retention) for TTL configuration options and [Deleting Snapshots](#deleting-snapshots) to learn how to manage snapshot storage. ### Forking Since Filesystem Snapshots are [Images](https://modal.com/docs/reference/modal.Image), you can create multiple Sandboxes from the same snapshot. Each Sandbox starts with an identical copy of the snapshotted filesystem, so you can use this to run parallel workloads or test different changes independently. {#snippet python()} ```python notest import modal app = modal.App.lookup("sandbox-fork-example", create_if_missing=True) sb = modal.Sandbox.create(app=app) p = sb.exec("bash", "-c", "pip install numpy && echo 'setup done' > /status") p.wait() image = sb.snapshot_filesystem() sb.terminate() # Start multiple Sandboxes from the same snapshot sb2 = modal.Sandbox.create(image=image, app=app) sb3 = modal.Sandbox.create(image=image, app=app) # Each fork starts from the snapshotted state assert sb2.exec("cat", "/status").stdout.read().strip() == "setup done" assert sb3.exec("cat", "/status").stdout.read().strip() == "setup done" ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image); const p = await sb.exec([ "bash", "-c", "pip install numpy && echo 'setup done' > /status", ]); await p.wait(); const snapshot = await sb.snapshotFilesystem(); await sb.terminate(); // Start multiple Sandboxes from the same snapshot const sb2 = await modal.sandboxes.create(app, snapshot); const sb3 = await modal.sandboxes.create(app, snapshot); // Each fork starts from the snapshotted state const p2 = await sb2.exec(["cat", "/status"]); console.assert((await p2.stdout.readText()).trim() === "setup done"); const p3 = await sb3.exec(["cat", "/status"]); console.assert((await p3.stdout.readText()).trim() === "setup done"); ``` {/snippet} {#snippet go()} ```go notest sb, _ := mc.Sandboxes.Create(ctx, app, image, nil) p, _ := sb.Exec(ctx, []string{"bash", "-c", "pip install numpy && echo 'setup done' > /status"}, nil) p.Wait(ctx, nil) snapshot, _ := sb.SnapshotFilesystem(ctx, nil) sb.Terminate(ctx, nil) // Start multiple Sandboxes from the same snapshot sb2, _ := mc.Sandboxes.Create(ctx, app, snapshot, nil) sb3, _ := mc.Sandboxes.Create(ctx, app, snapshot, nil) // Each fork starts from the snapshotted state p2, _ := sb2.Exec(ctx, []string{"cat", "/status"}, nil) stdout2, _ := io.ReadAll(p2.Stdout) fmt.Println(strings.TrimSpace(string(stdout2))) // "setup done" p3, _ := sb3.Exec(ctx, []string{"cat", "/status"}, nil) stdout3, _ := io.ReadAll(p3.Stdout) fmt.Println(strings.TrimSpace(string(stdout3))) // "setup done" ``` {/snippet} ## Directory Snapshots Directory Snapshots allow you to snapshot a specific directory within a running Sandbox. The resulting snapshot is an Image that can then be mounted into another already-running Sandbox (typically at a later time), which can be useful for: - **Updating system dependencies separately from application code**: Base dependencies can be updated by starting a new Sandbox from an updated base Image, and then mounting in previously snapshotted application code. - **Using warm pools in combination with snapshots**: For use cases that benefit from a [warm pool](https://modal.com/docs/examples/sandbox_pool) of Sandboxes to reduce start-up latency, the first initialization can now happen in the warm pool without losing the ability to restore application-specific code at a later point in time. - **Speeding up resumptions of previous sessions**: Files in mounted Images are prioritized when containers load files, so mounting a directory can speed up Sandbox resumptions vs. starting from a full file system image. ### Usage Use `snapshot_directory` to snapshot a directory, `mount_image` to mount a previous directory snapshot at a directory path, and `unmount_image` to remove that mounted Image later. To protect directory snapshots with customer-held key material, see [Customer Supplied Encryption Keys](https://modal.com/docs/guide/customer-supplied-encryption-keys#directory-snapshots). {#snippet python()} ```python notest sb = modal.Sandbox.create(app=app) # Write some dummy data sb.exec("bash", "-c", "mkdir /project && echo 'data' > /project/file.txt").wait() # Snapshot the directory snapshot = sb.snapshot_directory("/project") # Ok to throw away the old Sandbox at this point sb.terminate() # Mount the snapshot in a new Sandbox sb2 = modal.Sandbox.create(app=app) try: sb2.mount_image("/project", snapshot) except modal.exception.NotFoundError: # Handle a potential ttl expiry of the old snapshot here ... # The Sandbox now has access to the previous project state assert sb2.exec("cat", "/project/file.txt").stdout.read().strip() == "data" ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image); // Write some dummy data const p = await sb.exec([ "bash", "-c", "mkdir /project && echo 'data' > /project/file.txt", ]); await p.wait(); // Snapshot the directory const snapshot = await sb.snapshotDirectory("/project"); // Ok to throw away the old Sandbox at this point await sb.terminate(); sb.detach(); // Mount the snapshot in a new Sandbox const sb2 = await modal.sandboxes.create(app, image); try { await sb2.mountImage("/project", snapshot); } catch (e) { if (e instanceof NotFoundError) { // Handle a potential ttl expiry of the old snapshot here } } // The Sandbox now has access to the previous project state const p2 = await sb2.exec(["cat", "/project/file.txt"]); console.assert((await p2.stdout.readText()).trim() === "data"); sb2.detach(); ``` {/snippet} {#snippet go()} ```go notest sb, _ := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Detach() // Write some dummy data p, _ := sb.Exec(ctx, []string{"bash", "-c", "mkdir /project && echo 'data' > /project/file.txt"}, nil) p.Wait(ctx, nil) // Snapshot the directory snapshot, _ := sb.SnapshotDirectory(ctx, "/project", nil) // Ok to throw away the old Sandbox at this point sb.Terminate(ctx, nil) // Mount the snapshot in a new Sandbox sb2, _ := mc.Sandboxes.Create(ctx, app, image, nil) defer sb2.Detach() if err := sb2.MountImage(ctx, "/project", snapshot, nil); err != nil { var notFound modal.NotFoundError if errors.As(err, ¬Found) { // Handle a potential ttl expiry of the old snapshot here } } // The Sandbox now has access to the previous project state p2, _ := sb2.Exec(ctx, []string{"cat", "/project/file.txt"}, nil) stdout, _ := io.ReadAll(p2.Stdout) fmt.Println(strings.TrimSpace(string(stdout))) // "data" ``` {/snippet} ### Unmounting a mounted Image To unmount a previously mounted Image, call `unmount_image` on the exact path you passed to `mount_image`. After unmounting, the underlying Sandbox filesystem at that path becomes visible again. {#snippet python()} ```python notest sb2.unmount_image("/project") ``` {/snippet} {#snippet javascript()} ```javascript notest await sb2.unmountImage("/project"); ``` {/snippet} {#snippet go()} ```go notest _ = sb2.UnmountImage(ctx, "/project", nil) ``` {/snippet} ## Memory Snapshots A number of known [limitations](#limitations) currently apply. Sandbox memory snapshots are copies of a Sandbox’s entire state, both in memory and on the filesystem. These Snapshots can be restored later to create a new Sandbox, which is an exact clone of the original Sandbox. To snapshot a Sandbox, create it with `_experimental_enable_snapshot` set to `True`, and use the `_experimental_snapshot` method, which returns a `SandboxSnapshot` object: ```python notest image = modal.Image.debian_slim().apt_install("curl", "procps") app = modal.App.lookup("sandbox-snapshot", create_if_missing=True) with modal.enable_output(): sb = modal.Sandbox.create( "python3", "-m", "http.server", "8000", app=app, image=image, _experimental_enable_snapshot=True ) print(f"Performing snapshot of {sb.object_id} ...") snapshot = sb._experimental_snapshot() ``` Create a new Sandbox from the returned SandboxSnapshot with `Sandbox._experimental_from_snapshot`: ```python notest print(f"Restoring from snapshot {sb.object_id} ...") sb2 = modal.Sandbox._experimental_from_snapshot(snapshot) print("Let's see that the http.server is still running...") p = sb2.exec("ps", "aux") print(p.stdout.read()) # Talk to snapshotted Sandbox http.server p = sb2.exec("curl", "http://localhost:8000/") reply = p.stdout.read() print(reply) # ```python import modal # Create an image for the parent Modal Sandbox, with Docker installed. def create_modal_sandbox_image(): image = ( modal.Image.from_registry("ubuntu:24.04") .env({"DEBIAN_FRONTEND": "noninteractive"}) .apt_install(["docker.io", "docker-buildx"]) .run_commands("mkdir /build") ) return image def main(): print("Looking up modal.Sandbox app") app = modal.App.lookup("docker-test", create_if_missing=True) print("Creating sandbox") with modal.enable_output(): sb = modal.Sandbox.create( "/usr/bin/dockerd", "-D", timeout=60 * 60, app=app, image=create_modal_sandbox_image(), experimental_options={"vm_runtime": True}, ) print(f"sandbox_id: {sb.object_id}") task_id = sb._get_task_id() print(f"task_id: {task_id}") print(f"To shell into the task, run: modal shell {task_id}") # dockerd is the sandbox entrypoint and takes a moment to bind # /var/run/docker.sock after the sandbox is created. Poll until the # daemon answers so the first `docker build` doesn't run before dockerd is ready. print("Waiting for dockerd to be ready") wait_p = sb.exec( "sh", "-c", "for i in $(seq 1 120); do " "if [ -S /var/run/docker.sock ] && docker info >/dev/null 2>&1; then " "echo ready; exit 0; fi; sleep 1; done; " "echo 'dockerd not ready after 120s' >&2; exit 1", ) wait_p.wait() if wait_p.returncode != 0: raise Exception(f"dockerd never became ready: {wait_p.stderr.read()}") # A simple Dockerfile that we'll build and run within Modal. dockerfile = """ FROM ubuntu RUN apt-get update RUN apt-get install -y cowsay curl RUN mkdir -p /usr/share/cowsay/cows/ RUN curl -o /usr/share/cowsay/cows/docker.cow https://raw.githubusercontent.com/docker/whalesay/master/docker.cow ENTRYPOINT ["/usr/games/cowsay", "-f", "docker.cow"] """ sb.filesystem.write_text(dockerfile, "/build/Dockerfile") print("Building docker image") p = sb.exec("docker", "build", "-t", "whalesay", "/build") for l in p.stdout: print(l, end="") p.wait() print("--------------------------------") if p.returncode != 0: print(p.stderr.read()) raise Exception("Docker build failed") # The Sandbox will run a container from the built image and print this: # # ________ # < Hello! > # -------- # \ # \ # \ # ## . # ## ## ## == # ## ## ## ## ## === # /"""""""""""""""""\___/ === # { / ===- # \______ O __/ # \ \ __/ # \____\_______/ print("Running Docker image") # Note we can't use -it here because we're not in a TTY. p = sb.exec("docker", "run", "--rm", "whalesay", "Hello!") print(p.stdout.read()) p.wait() if p.returncode != 0: raise Exception(f"Docker run failed: {p.stderr.read()}") sb.terminate() if __name__ == "__main__": main() ``` Additionally, quickly provision a VM Sandbox with a PTY shell via the CLI using: ``` modal shell --experimental-option vm_runtime=1 ``` ## Running custom init systems By default, Modal runs and manages the init process (PID 1) inside the VM. Set the `vm_init` experimental option to an absolute path (typically, `/sbin/init`) to run a conventional init system such as [`systemd`](https://man7.org/linux/man-pages/man1/systemd.1.html) or [`openrc`](https://github.com/OpenRC/openrc) as PID 1 instead. Modal's agent runs alongside the `vm_init`-specified init process. ```python fixture:sb_app image = modal.Image.from_registry("debian:bookworm-slim").dockerfile_commands( "RUN apt-get update", # udev is needed so that getty doesn't block for 90s waiting on device activation. "RUN apt-get install -y systemd systemd-sysv dbus udev", # An empty machine-id tells systemd to generate one at first boot. # systemd won't start if the file is missing entirely. "RUN rm -f /etc/machine-id && touch /etc/machine-id", ) sb = modal.Sandbox.create( app=sb_app, image=image, cpu=4, memory=2048, readiness_probe=modal.Probe.with_exec( "systemctl", "is-system-running", interval_ms=250 ), experimental_options={"vm_runtime": True, "vm_init": "/sbin/init"}, ) try: sb.wait_until_ready() finally: sb.terminate() ``` ## Improvements over gVisor sandboxes Docker workloads behave more like they do in a non-container environment. In particular: - Docker state (e.g. `/var/lib/docker`) is included in [Filesystem Snapshots](https://modal.com/docs/guide/sandbox-snapshots#filesystem-snapshots) - Docker features that previously needed special treatment on gVisor (e.g. inter-container networking) will also work normally Features that only make sense in a bona fide Linux environment are now available: - Custom [init systems](https://arxiv.org/pdf/0706.2748) (such as [`systemd`](https://man7.org/linux/man-pages/man1/systemd.1.html)) are supported - [eBPF](https://ebpf.io/) is supported - [FUSE](https://www.kernel.org/doc/html/latest/filesystems/fuse.html) mounts are supported - Resource isolation within the Sandbox via [cgroups](https://man7.org/linux/man-pages/man7/cgroups.7.html) is supported Finally, for most workloads, the root filesystem will perform better on a VM Sandbox than in a gVisor Sandbox. ## Resource model Unlike [resource provisioning](https://modal.com/docs/guide/resources) in other runtimes, memory provisioning is **static** for VM Sandboxes: you get exactly as much RAM as you request via `memory` argument to `Sandbox.create`. By default, VM sandboxes get 1GiB of RAM. However, CPU provisioning is elastic. You can burst above your requested amount. Costs for both resources are calculated based on the requested amount, used amount, the duration of Sandbox execution, and [our rates for `cpu` and `memory`](https://modal.com/pricing). ## Limitations The following limitations are known and we're tracking them: - **GPUs are not supported.** VM Sandboxes currently only support CPU workloads. - **The [Sandbox filesystem API](https://modal.com/docs/guide/sandbox-files#filesystem-api-beta) is only available in new SDK versions**. For the Python SDK, it requires version ≥ 1.4.0 and for the JS/TS/Go SDKs, it requires versions ≥ 0.7.6. - **[`Sandbox.reload_volumes()`](https://modal.com/docs/sdk/py/latest/Sandbox#reload_volumes) is not supported.** VM Sandboxes do not currently support reloading volumes at runtime. - **[VM Memory Snapshots](https://modal.com/docs/guide/vm-memory-snapshots) are only available to a set of enabled customers.** Please reach out to us if you'd like early access. - **Root images ≥ 512 GiB are not supported.** The VM root filesystem is currently limited to 512 GiB. Sandboxes created from container images exceeding this size will fail to start. If you hit a rough edge that isn't listed here, please reach out via [Slack](https://modal.com/slack) or email us at [support@modal.com](mailto:support@modal.com). #### Sidecars (Alpha) # Sandbox Sidecars There are currently several [known limitations](#limitations). ## Introduction Sandbox Sidecars let you run additional containers alongside your main Sandbox container, on the same host. A sandbox and its sidecars are connected via an internal bridge network, allowing low latency communication between containers over TCP/UDP, making them ideal for: - Separating an agent harness from its execution environment, by running the agent in one container and its tool calls in another - Credentials injection, by running a proxy in a separate, trusted container from the primary application, and letting that proxy inject credentials or other secrets before passing on network calls to external services. See the [secrets injection example](https://modal.com/docs/examples/sidecar_secrets_injection) for a working demonstration - Splitting out complex multi-service applications over separate containers, such as databases, caches or worker processes, similar to Docker Compose. We're still discovering all the ways that Sandbox Sidecars can be used - if you come up with another use case, please let us know! Sidecars are managed through the sidecars interface on a Sandbox (`_experimental_sidecars` in Python, `experimentalSidecars` in JS/Go), which provides methods to create, list, get, and terminate Sidecar containers. Each Sidecar container: - Runs its own image independently from the main Sandbox container. - Runs in a separate, sandboxed process isolated from the main Sandbox container and other Sidecar containers. - Can communicate over an internal bridge network with the main Sandbox container and other Sidecar containers. - Can be created, terminated, and replaced dynamically during the Sandbox's lifetime. - Supports executing commands just like the main Sandbox container. ## Usage ### Creating a Sidecar container The main Sandbox container is resolvable as `main`, and each Sidecar container is resolvable by the `name` you give it at creation time. {#snippet python()} ```python notest import modal app = modal.App.lookup("sidecar-example", create_if_missing=True) image = modal.Image.debian_slim().build(app) sb = modal.Sandbox.create("sleep", "600", app=app, image=image, timeout=300) sidecar = sb._experimental_sidecars.create( "python", "-m", "http.server", "8080", name="web", image=image, ) # Give the server a moment to start, then call it from the main sandbox. p = sb.exec( "python", "-c", "import time, urllib.request; time.sleep(1); print(urllib.request.urlopen('http://web:8080').status)", ) p.wait() print(p.stdout.read()) # "200" sb.terminate() ``` {/snippet} {#snippet javascript()} ```javascript notest const modal = new ModalClient(); const app = await modal.apps.fromName("sidecar-example", { createIfMissing: true, }); const image = await modal.images.fromRegistry("python:3.13-slim").build(app); const sb = await modal.sandboxes.create(app, image, { command: ["sleep", "600"], timeoutMs: 300 * 1000, }); const sidecar = await sb.experimentalSidecars.create("web", image, { command: ["python", "-m", "http.server", "8080"], }); // Give the server a moment to start, then call it from the main sandbox. const p = await sb.exec([ "python", "-c", "import time, urllib.request; time.sleep(1); print(urllib.request.urlopen('http://web:8080').status)", ]); await p.wait(); console.log(await p.stdout.readText()); // "200" await sb.terminate(); ``` {/snippet} {#snippet go()} ```go package main import ( "context" "fmt" "io" "time" modal "github.com/modal-labs/modal-client/go" ) func main() { ctx := context.Background() mc, _ := modal.NewClient() app, _ := mc.Apps.FromName(ctx, "sidecar-example", &modal.AppFromNameParams{ CreateIfMissing: true, }) image, _ := mc.Images.FromRegistry("python:3.13-slim", nil).Build(ctx, app, nil) sb, _ := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"sleep", "600"}, Timeout: 5 * time.Minute, }) defer sb.Terminate(ctx, nil) sidecar, _ := sb.ExperimentalSidecars.Create(ctx, "web", image, &modal.SidecarCreateParams{ Command: []string{"python", "-m", "http.server", "8080"}, }) _ = sidecar // Give the server a moment to start, then call it from the main sandbox. p, _ := sb.Exec(ctx, []string{ "python", "-c", "import time, urllib.request; time.sleep(1); print(urllib.request.urlopen('http://web:8080').status)", }, nil) stdout, _ := io.ReadAll(p.Stdout) fmt.Println(string(stdout)) // "200" } ``` {/snippet} Names are resolved using `/etc/hosts` which gets updated when a sidecar is created or terminated. ### Listing and retrieving sidecars You can list all running Sidecar containers or retrieve a specific one by name: {#snippet python()} ```python notest containers = sb._experimental_sidecars.list() for container in containers: print(f"{container.name}: {container.object_id}") sidecar = sb._experimental_sidecars.get(name="web") ``` {/snippet} {#snippet javascript()} ```javascript notest const containers = await sb.experimentalSidecars.list(); for (const container of containers) { console.log(`${container.containerName}: ${container.containerId}`); } const sidecar = await sb.experimentalSidecars.get("web"); ``` {/snippet} {#snippet go()} ```go notest containers, _ := sb.ExperimentalSidecars.List(ctx, nil) for _, container := range containers { fmt.Printf("%s: %s\n", container.ContainerName, container.ContainerID) } sidecar, _ := sb.ExperimentalSidecars.Get(ctx, "web", nil) _ = sidecar ``` {/snippet} ### Routing HTTPS traffic through a Sidecar Sidecars can be used to inspect the outgoing HTTPS traffic from the main Sandbox container in a different context, for example to perform more advanced request filtering, inspecting requests for logging, or injecting secrets that the main Sandbox container should not have access to. Normally, applications need to support explicit proxy configuration, such as respecting the `HTTPS_PROXY` environment variable, to route traffic through a Sidecar. To include HTTPS traffic (TCP on port 443) from **proxy-unaware** applications, you can set the experimental `proxy_traffic_via_sidecar` option that routes **all** outbound HTTPS traffic from the main Sandbox container through a Sidecar. {#snippet python()} ```python notest sb = modal.Sandbox.create( "sleep", "600", app=app, image=image, experimental_options={"proxy_traffic_via_sidecar": "my-proxy-sidecar"}, ) # Until this Sidecar is running, HTTPS from the main container is refused. sb._experimental_sidecars.create("python", "/proxy.py", name="my-proxy-sidecar", image=proxy_image) ``` {/snippet} {#snippet javascript()} ```javascript notest const sb = await modal.sandboxes.create(app, image, { command: ["sleep", "600"], experimentalOptions: { proxy_traffic_via_sidecar: "my-proxy-sidecar" }, }); // Until this Sidecar is running, HTTPS from the main container is refused. await sb.experimentalSidecars.create("my-proxy-sidecar", proxyImage, { command: ["python", "/proxy.py"], }); ``` {/snippet} {#snippet go()} ```go notest sb, _ := mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ Command: []string{"sleep", "600"}, ExperimentalOptions: map[string]any{"proxy_traffic_via_sidecar": "my-proxy-sidecar"}, }) // Until this Sidecar is running, HTTPS from the main container is refused. sb.ExperimentalSidecars.Create(ctx, "my-proxy-sidecar", proxyImage, &modal.SidecarCreateParams{ Command: []string{"python", "/proxy.py"}, }) ``` {/snippet} The Sidecar receives a raw TLS stream and must read the destination hostname from the `ClientHello`'s SNI. The original destination IP is not forwarded, so mechanisms such as `SO_ORIGINAL_DST` do not work. To read or rewrite HTTP requests, terminate TLS in the proxy using a certificate authority that the Sandbox trusts. See the [Sidecar traffic routing example](https://modal.com/docs/examples/sidecar_traffic_routing) for a complete mitmproxy-based request filter. Only TCP traffic to port 443 is relayed. The Sandbox's own egress controls are set aside for it: an `outbound_cidr_allowlist` on the Sandbox still governs every other port, but relayed traffic passes regardless of what it lists. Non-relayed traffic is still subject to the egress controls of the Sandbox. Relayed traffic is instead governed by the egress controls of the Sidecar it is relayed into. A Sidecar's outbound network policy is independent of the main container's and defaults to open, so unless you pass `outbound_cidr_allowlist` or `outbound_domain_allowlist` to the Sidecar itself, relayed traffic reaches any destination the Sidecar chooses to connect to. The option cannot be combined with setting `block_network`, `outbound_domain_allowlist` or `proxy` on the Sandbox. ### Filesystem snapshots You can snapshot a running Sidecar's filesystem into a reusable Image. The resulting Image can be used anywhere an existing Image is accepted; the example below uses it to start another Sidecar. The snapshot is scoped to that Sidecar; it does not include the main Sandbox filesystem or other Sidecars. {#snippet python()} ```python notest sidecar.filesystem.write_text("ready", "/tmp/state") snapshot = sidecar.snapshot_filesystem() restored = sb._experimental_sidecars.create( "sleep", "600", name="restored", image=snapshot ) assert restored.filesystem.read_text("/tmp/state") == "ready" ``` {/snippet} {#snippet javascript()} ```javascript notest await sidecar.filesystem.writeText("ready", "/tmp/state"); const snapshot = await sidecar.snapshotFilesystem(); const restored = await sb.experimentalSidecars.create("restored", snapshot, { command: ["sleep", "600"], }); console.assert((await restored.filesystem.readText("/tmp/state")) === "ready"); ``` {/snippet} {#snippet go()} ```go notest _ = sidecar.Filesystem.WriteText(ctx, "ready", "/tmp/state", nil) snapshot, _ := sidecar.SnapshotFilesystem(ctx, nil) restored, _ := sb.ExperimentalSidecars.Create(ctx, "restored", snapshot, &modal.SidecarCreateParams{ Command: []string{"sleep", "600"}, }) state, _ := restored.Filesystem.ReadText(ctx, "/tmp/state", nil) fmt.Println(state) // "ready" ``` {/snippet} ## Resource configuration The main Sandbox container and the Sidecar containers share the resource allocation (CPU and memory) of the Sandbox, and resources are configured only on the Sandbox. When planning your resource allocation, make sure the Sandbox is configured with enough CPU and memory for all containers combined. Bursting is still possible, see the [guide to Sandbox resources and pricing](https://modal.com/docs/guide/sandbox-resources) for more details. For example, if you want to run a Sandbox with two Sidecars, and you expect the main container to use 1 CPU core and 512 MiB of memory, Sidecar A to use 0.5 CPU and 256 MiB, and Sidecar B to use 0.5 CPU and 256 MiB, you should set the Sandbox's resources to at least 2 CPUs and 1024 MiB to accommodate all three containers. The maximum number of Sidecars you can create is also determined by the main Sandbox's resource reservation. Each container (including the main one) requires a minimum of 32 mCPU and 32 MiB of memory, so the limit is: max containers = min(cpu_in_milli / 32, memory_in_mib / 32) There is also a hard limit of **250** concurrent sidecar containers per sandbox, regardless of the resource reservation. ## Limitations The main sandbox supports the same features as a regular sandbox, but some features are not yet supported for sidecars: - **Pre-built images only**: Sidecar images must be pre-built using `image.build()`, referenced by ID via `Image.from_id()` or name via `Image.from_name()`, or created from filesystem/directory snapshots. Lazy image building is not supported for sidecars. See also [Separating Image builds from Sandbox creation](https://modal.com/docs/guide/sandboxes#separating-image-builds-from-sandbox-creation). - **No Cloud Bucket Mount support**: Sidecar containers do not currently support attaching [Cloud Bucket Mounts](https://modal.com/docs/guide/cloud-bucket-mounts). - **No memory snapshot support**: A Sidecar's filesystem can be snapshotted independently, but Sidecar memory state is not captured in [Sandbox snapshots](https://modal.com/docs/guide/sandbox-snapshots). - **VM incompatibility**: Sidecars are not compatible with VM Sandboxes. - **Changes to /etc/hosts are not preserved**: `/etc/hosts` is rewritten on sidecar create/terminate and user changes are not preserved. - **Maximum of 250 concurrent sidecars**: A sandbox can have at most 250 sidecar containers running at the same time. - **No [Proxy](https://modal.com/docs/guide/proxy-ips) support**: Traffic from a Sidecar does not exit through a Proxy. Because relayed traffic leaves from the Sidecar, a Sandbox cannot currently combine a Proxy with `proxy_traffic_via_sidecar`. ### Endpoints #### Endpoints # Endpoints Modal Endpoints let you deploy models from the [Modal Library](https://modal.com/library)—or your own custom weights—as production-ready inference APIs. - **Fast inference** — tuned open-source serving engines, with speculative decoding where supported, behind Modal's low-latency request proxy. - **Managed infrastructure** — Modal handles provisioning, routing, and capacity management. - **Open and inspectable** — use familiar OpenAI- and Anthropic-compatible APIs; [inspect or adapt the generated source](https://modal.com/docs/guide/dedicated-endpoints#view-the-generated-source) behind a Dedicated Endpoint. Choose between two serving modes: | | [Shared Endpoints](https://modal.com/docs/guide/shared-endpoints) | [Dedicated Endpoints](https://modal.com/docs/guide/dedicated-endpoints) | | ------------ | ------------------------------------------------ | ------------------------------------------------------ | | **Best for** | Fast, fully managed inference | Isolated capacity and custom models | | **Models** | Selected models from the Modal Library | All Modal Library models, plus custom weights | | **Billing** | Per token | Compute resources | | **Capacity** | Managed by Modal | Configurable autoscaling, including scale-to-zero | ## Create an endpoint Browse the [Modal Library](https://modal.com/library) to choose a model and see which serving modes it supports. Then create an Endpoint from the [**Endpoints**](https://modal.com/endpoints) tab in the dashboard. ## Proxy tokens Shared Endpoints always require a [Proxy Token](https://modal.com/docs/guide/webhook-proxy-auth). Dedicated Endpoints require one by default. Create one with the CLI: ```bash modal workspace proxy-tokens create ``` Join the token ID and secret with a period (`.`) and pass them as a bearer token: ``` Authorization: Bearer wk-.ws- ``` The combined value can be used as the API key in an OpenAI-compatible client. See [Proxy Tokens](https://modal.com/docs/guide/webhook-proxy-auth) for environment scoping and other authentication options. Dedicated Endpoints can also be created with `--unauthenticated`. ## Call an endpoint Text-generation models on both Shared and Dedicated Endpoints can be called through the OpenAI-compatible Chat Completions and Responses APIs or the Anthropic-compatible Messages API. Embedding models can be called through the OpenAI-compatible Embeddings API. The dashboard shows the Endpoint URL and model name. This example uses the Chat Completions API and a [proxy token](#proxy-tokens): ```bash curl "/v1/chat/completions" \ -H "Authorization: Bearer $MODAL_PROXY_TOKEN_ID.$MODAL_PROXY_TOKEN_SECRET" \ -H "Content-Type: application/json" \ -d '{ "model": "", "messages": [{ "role": "user", "content": "Hello!" }] }' ``` See [Endpoint integrations](https://modal.com/docs/guide/endpoint-integrations) for connecting coding agents like OpenCode, Codex, and Claude Code to a Shared Endpoint. #### Shared Endpoints # Shared Endpoints Shared Endpoints serve a subset of models in the [Modal Library](https://modal.com/library) from Modal-managed pools. Every model available for Shared Endpoints is also available for [Dedicated Endpoints](https://modal.com/docs/guide/dedicated-endpoints). The Library shows which serving modes each model supports. Modal manages the hardware and autoscaling, and usage is billed per token. ## Create a Shared Endpoint Create a Shared Endpoint from the [**Endpoints**](https://modal.com/endpoints) tab in the dashboard. Once the Endpoint is ready, the dashboard provides its URL, model name, and request examples. Shared Endpoints always require a [proxy token](https://modal.com/docs/guide/endpoints#proxy-tokens). ## Concurrency limits Shared Endpoints have model-specific limits on concurrent in-flight requests. The limit is shared across all Shared Endpoints using the same model in your Workspace. Requests above the limit receive an HTTP `429` response; retry them with exponential backoff and jitter. Use a [Dedicated Endpoint](https://modal.com/docs/guide/dedicated-endpoints) when you need isolated or configurable capacity. ## Pricing Token rates are shown when you create an Endpoint and in its Usage view. Credits included with your plan cannot be used for Shared Endpoint usage. To cap out-of-pocket charges, see [spend limits](https://modal.com/docs/guide/budgets#spend-limits). Shared Endpoint requests route through `us-west` and always require authentication. Modal manages their infrastructure and capacity. #### Dedicated Endpoints # Dedicated Endpoints Dedicated Endpoints run any model in the [Modal Library](https://modal.com/library), or custom weights, on isolated, autoscaling containers. Use them when you need control over autoscaling and regions, or dedicated capacity. ## Create a Dedicated Endpoint Create an Endpoint from the CLI: ```bash modal endpoint create --model Qwen/Qwen3.5-4B ``` Modal resolves the model, selects a compatible serving recipe, and starts provisioning. The command prints the Endpoint ID and a dashboard link where you can watch it come online. Dedicated Endpoints can also be created from the [**Endpoints**](https://modal.com/endpoints) tab in the dashboard. If you omit `--name`, Modal derives a name from the model. ## View the generated source A Dedicated Endpoint is a Modal App built with the same primitives available in the Modal SDK, including [`@app.server()`](https://modal.com/docs/guide/servers). Open the **Source** view to inspect its generated `serve.py`. You can copy and adapt that code into your own Modal App when you need full control of the serving stack. ## Serve custom weights Custom weights use the serving recipe for a compatible Modal Library model. Pass that model with `--model`, then provide weights from Hugging Face or a Modal Volume. From Hugging Face: ```bash modal endpoint create \ --name my-fine-tune \ --model Qwen/Qwen3.6-27B \ --custom-hf-repo aisingapore/Qwen-SEA-LION-v4.5-27B-IT \ --custom-hf-revision da42f2c0984d716fb2032e4176d81adfac98c630 ``` Use `--custom-hf-token` for gated or private repositories. From a Modal Volume containing a `config.json` file: ```bash modal endpoint create \ --name my-volume-model \ --model Qwen/Qwen3.5-4B \ --custom-volume-name my-volume \ --custom-volume-path /checkpoints/1234 ``` ## Configure capacity and placement By default, Dedicated Endpoints scale up under load and down to zero when idle. Configure minimum, maximum, and buffer containers from the dashboard. The routing region controls where requests enter Modal. Compute placement controls where containers run. Set them independently: ```bash modal endpoint create \ --model Qwen/Qwen3.5-4B \ --routing-region us-east \ --compute-region us-west ``` Use `--colocate-compute` instead to run compute in the routing region. Pinning compute to a region incurs a [region selection multiplier](https://modal.com/docs/guide/region-selection#pricing). ## Metrics The **Activity** view shows request volume over time. Use **Responses** to inspect individual requests and **Containers** to inspect the containers serving them. For text-generation models, **Metrics** separates **Inference metrics**—latency, throughput, running and queued requests, cache usage, and speculative decoding—from **Server metrics** such as autoscaling and CPU, memory, network, and GPU utilization. ## Benchmarks For text-generation models, the **Benchmark** view can run a repeatable real-time or agentic workload against the live Endpoint. Benchmarks generate traffic, trigger autoscaling, and incur the usual compute cost. Treat results as point-in-time measurements: fleet size, placement, and cold starts can all affect them. ## Manage a Dedicated Endpoint List Endpoints in an Environment: ```bash modal endpoint list --env prod ``` Stop an Endpoint when you no longer need it: ```bash modal endpoint stop my-endpoint --env prod ``` Stopping an Endpoint is permanent. It tears down the serving application and deletes its managed model cache; the Endpoint cannot be restarted. ## Pricing Dedicated Endpoints bill for the GPU, CPU, memory, and other resources used by their containers at standard Modal compute rates. An Endpoint scaled to zero does not incur compute charges. #### Endpoint integrations # Endpoint integrations Connect OpenCode, Codex, or Claude Code to your [Shared Endpoints](https://modal.com/docs/guide/shared-endpoints) through `https://inference.us-west.modal.direct`. Set the request's `model` to the Endpoint's hostname, for example `my-endpoint.us-west.modal.direct`. To see which Shared Endpoints a token can reach, list all model IDs with: ```bash curl "https://inference.us-west.modal.direct/v1/models" \ -H "Authorization: Bearer $MODAL_PROXY_TOKEN_ID.$MODAL_PROXY_TOKEN_SECRET" ``` ## OpenCode [Install OpenCode](https://opencode.ai/docs/) and create a [proxy token](https://modal.com/docs/guide/endpoints#proxy-tokens). In the OpenCode CLI, run `/connect`, select Modal as the [provider](https://opencode.ai/docs/providers/), and enter the token as the API key in its combined form, `wk-.ws-`. Then run `/models` and select your endpoint by hostname. For CI or other headless use, set the token in the environment instead of running `/connect`: ```bash export MODAL_PROXY_TOKEN="wk-.ws-" ``` ## Codex [Install Codex](https://learn.chatgpt.com/docs/codex/cli), create a [proxy token](https://modal.com/docs/guide/endpoints#proxy-tokens), and define Modal as a model provider in `~/.codex/config.toml`: ```toml # ~/.codex/config.toml [model_providers.modal] name = "Modal" base_url = "https://inference.us-west.modal.direct/v1" env_key = "MODAL_PROXY_TOKEN" wire_api = "responses" ``` Then you can run Codex via the following command with the endpoint hostname as the model ID: ```bash export MODAL_PROXY_TOKEN="$MODAL_PROXY_TOKEN_ID.$MODAL_PROXY_TOKEN_SECRET" codex \ --model my-endpoint.us-west.modal.direct \ --config model_provider='"modal"' ``` ## Claude Code [Install Claude Code](https://code.claude.com/docs/en/quickstart) and create a [Proxy Token](https://modal.com/docs/guide/endpoints#proxy-tokens). Set the token in its combined form, `wk-.ws-`, and replace `my-endpoint.us-west.modal.direct` with the hostname of an Endpoint whose model supports tool calling: ```bash export ANTHROPIC_BASE_URL="https://inference.us-west.modal.direct" export ANTHROPIC_AUTH_TOKEN="wk-.ws-" export ANTHROPIC_MODEL="my-endpoint.us-west.modal.direct" export ANTHROPIC_DEFAULT_FABLE_MODEL="$ANTHROPIC_MODEL" export ANTHROPIC_DEFAULT_OPUS_MODEL="$ANTHROPIC_MODEL" export ANTHROPIC_DEFAULT_SONNET_MODEL="$ANTHROPIC_MODEL" export ANTHROPIC_DEFAULT_HAIKU_MODEL="$ANTHROPIC_MODEL" claude ``` ### Images #### Defining Images # Images This guide walks you through how to define a Modal Image, the environment your Modal code runs in. The typical flow for defining an Image in Modal is [method chaining](https://jugad2.blogspot.com/2016/02/examples-of-method-chaining-in-python.html) starting from a base Image, like this: ```python image = ( modal.Image.debian_slim(python_version="3.13") .apt_install("git") .uv_pip_install("torch<3") .env({"HALT_AND_CATCH_FIRE": "0"}) .run_commands("git clone https://github.com/modal-labs/agi && echo 'ready to go!'") ) ``` If you have your own container image definitions, like a Dockerfile or a registry link, you can use those too! See [this guide](https://modal.com/docs/guide/existing-images). This page is a high-level guide to using Modal Images. For reference documentation on the `modal.Image` object, see [this page](https://modal.com/docs/sdk/py/latest/Image). ## What are Images? Your code on Modal runs in _containers_. Containers are like light-weight virtual machines -- container engines use [operating system tricks](https://earthly.dev/blog/chroot/) to isolate programs from each other ("containing" them), making them work as though they were running on their own hardware with their own filesystem. This makes execution environments more reproducible, for example by preventing accidental cross-contamination of environments on the same machine. For added security, Modal runs containers using the sandboxed [gVisor container runtime](https://cloud.google.com/blog/products/identity-security/open-sourcing-gvisor-a-sandboxed-container-runtime). Containers are started up from a stored "snapshot" of their filesystem state called an _image_. Producing the image for a container is called _building_ the image. By default, Modal Functions and Sandboxes run in a [Debian Linux](https://en.wikipedia.org/wiki/Debian) container with a basic Python installation of the same minor version `v3.x` as your local Python interpreter. To make your Apps and Functions useful, you will probably need some third party system packages or Python libraries. Modal provides a number of options to customize your container images at different levels of abstraction and granularity, from high-level convenience methods like `pip_install` through wrappers of core container image build features like `RUN` and `ENV`. We'll cover each of these in this guide, along with tips and tricks for building Images effectively when using each tool. ## Add Python packages The simplest and most common Image modification is to add a third party Python package, like [`pandas`](https://pandas.pydata.org/). You can add Python packages to the environment by passing all the packages you need to the [`Image.uv_pip_install`](https://modal.com/docs/sdk/py/latest/Image#uv_pip_install) method, which installs packages with [`uv`](https://docs.astral.sh/uv/): ```python import modal datascience_image = ( modal.Image.debian_slim() .uv_pip_install("pandas==2.2.0", "numpy") ) @app.function(image=datascience_image) def my_function(): import pandas as pd import numpy as np df = pd.DataFrame() ... ``` You can include [Python dependency version specifiers](https://peps.python.org/pep-0508/), like `"torch<3"`, in the arguments. But we recommend pinning dependencies tightly, like `"torch==2.8.0"`, to improve the reproducibility and robustness of your builds. If you run into any issues with [`Image.uv_pip_install`](https://modal.com/docs/sdk/py/latest/Image#uv_pip_install), then you can fallback to [`Image.pip_install`](https://modal.com/docs/sdk/py/latest/Image#pip_install) which uses standard [`pip`](https://pip.pypa.io/en/stable/user_guide/): ```python datascience_image = ( modal.Image.debian_slim(python_version="3.13") .pip_install("pandas==2.2.0", "numpy") ) ``` Note that because you can define a different environment for each and every function if you so choose, you don't need to worry about virtual environment management. Containers make for much better separation of concerns! If you want to run a specific version of Python remotely rather than just matching the one you're running locally, provide the `python_version` as a string when constructing the base image, like we did above. ## Add local files with `add_local_dir` and `add_local_file` Sometimes your containers need a dependency that's not available on the Internet, like configuration files or code on your laptop. To forward files from your local system use the `image.add_local_dir` and `image.add_local_file` Image methods. ```python image = modal.Image.debian_slim().add_local_dir("/user/erikbern/.aws", remote_path="/root/.aws") ``` By default, these files are added to your container as it starts up rather than introducing a new Image layer. This means that the redeployment after making changes is really quick, but also means you can't run additional build steps after. You can specify a `copy=True` argument to the `add_local_` methods to instead force the files to be included in the built Image. ### Add local Python code with `add_local_python_source` You can add Python code that's importable locally to your container by providing the module name to [`Image.add_local_python_source`](https://modal.com/docs/sdk/py/latest/Image#add_local_python_source). ```python image_with_module = modal.Image.debian_slim().add_local_python_source("local_module") @app.function(image=image_with_module) def f(): import local_module local_module.do_stuff() ``` The difference from `add_local_dir` is that `add_local_python_source` takes module names as arguments instead of a file system path and looks up the local package's or module's location via Python's importing mechanism. The files are then added to directories that make them importable in containers in the same way as they are locally. This is intended for pure Python auxiliary modules that are part of your project and that your code imports. Third party packages should be installed via [`Image.uv_pip_install`](https://modal.com/docs/sdk/py/latest/Image#uv_pip_install) or similar. ### What if I have different Python packages locally and remotely? You might want to use packages inside your Modal code that you don't have on your local computer. In the example above, we build a container that uses `pandas`. But if we don't have `pandas` locally, on the computer building the Modal App, we can't put `import pandas` at the top of the script, since it would cause an `ImportError`. The easiest solution to this is to put `import pandas` in the function body instead, as you can see above. This means that `pandas` is only imported when running inside the remote Modal container, which has `pandas` installed. Be careful about what you return from Modal Functions that have different packages installed than the ones you have locally! Modal Functions return Python objects, like `pandas.DataFrame`s, and if your local machine doesn't have `pandas` installed, it won't be able to handle a `pandas` object (the error message you see will mention [serialization](https://hazelcast.com/glossary/serialization/)/[deserialization](https://hazelcast.com/glossary/deserialization/)). If you have a lot of Functions and a lot of Python packages, you might want to keep the imports in the global scope so that every function can use the same imports. In that case, you can use the [`Image.imports`](https://modal.com/docs/sdk/py/latest/Image#imports) context manager: ```python pandas_image = modal.Image.debian_slim().pip_install("pandas", "numpy") with pandas_image.imports(): import pandas as pd import numpy as np @app.function(image=pandas_image) def my_function(): df = pd.DataFrame() ... ``` Because these imports happen before a new container processes its first input, you can combine this context manager with [Memory Snapshots](https://modal.com/docs/guide/memory-snapshots) to improve [cold start performance](https://modal.com/docs/guide/cold-start#share-initialization-work-across-cold-starts-with-memory-snapshots) for Functions that frequently scale up. ## Install system packages with `.apt_install` You can install Linux packages with the [`apt` package manager](https://www.debian.org/doc/manuals/apt-guide/index.en.html) using [`Image.apt_install`](https://modal.com/docs/sdk/py/latest/Image#apt_install): ```python image = modal.Image.debian_slim().apt_install("git", "curl") ``` ## Set environment variables with `.env` You can change the environment variables that your code sees (in, e.g., [`os.environ`](https://docs.python.org/3/library/os.html#os.environ)) by passing a dictionary to [`Image.env`](https://modal.com/docs/sdk/py/latest/Image#env): ```python image = modal.Image.debian_slim().env({"PORT": "6443"}) ``` Environment variable names and values must be strings. ## Run shell commands with `.run_commands` You can supply shell commands that should be executed when building the Image to [`Image.run_commands`](https://modal.com/docs/sdk/py/latest/Image#run_commands): ```python image_with_repo = ( modal.Image.debian_slim().apt_install("git").run_commands( "git clone https://github.com/modal-labs/gpu-glossary" ) ) ``` ## Run a Python function during your build with `.run_function` You can run Python code as a build step using the [`Image.run_function`](https://modal.com/docs/sdk/py/latest/Image#run_function) method. For example, you can use this to download model parameters from Hugging Face into your Image: ```python import os def download_models() -> None: import diffusers model_name = "segmind/small-sd" pipe = diffusers.StableDiffusionPipeline.from_pretrained( model_name, use_auth_token=os.environ["HF_TOKEN"] ) hf_cache = modal.Volume.from_name("hf-cache") image = ( modal.Image.debian_slim() .pip_install("diffusers[torch]", "transformers", "ftfy", "accelerate") .run_function( download_models, secrets=[modal.Secret.from_name("huggingface-secret")], volumes={"/root/.cache/huggingface": hf_cache}, ) ) ``` For details on storing model weights on Modal, see [this guide](https://modal.com/docs/guide/model-weights). Essentially, this is equivalent to running a Modal Function and snapshotting the resulting filesystem as a new Image. Any kwargs accepted by [`@app.function`](https://modal.com/docs/sdk/py/latest/App#function) ([`Volume`s](https://modal.com/docs/guide/volumes), [`Secret`s](https://modal.com/docs/guide/secrets), specifications of resources like [GPUs](https://modal.com/docs/guide/gpu)) can be supplied here. Whenever you change other features of your Image, like the base Image or the version of a Python package, the Image will automatically be rebuilt the next time it is used. This is a bit more complicated when changing the contents of functions. See the [reference documentation](https://modal.com/docs/sdk/py/latest/Image#run_function) for details. ## Attach GPUs during setup If a step in the setup of your Image should be run on an instance with a GPU (e.g., so that a package can query the GPU to set compilation flags), pass the desired GPU type when defining that step: ```python image = ( modal.Image.debian_slim() .pip_install("bitsandbytes", gpu="H100") ) ``` ## Use `mamba` instead of `pip` with `micromamba_install` `pip` installs Python packages, but some Python workloads require the coordinated installation of system packages as well. The `mamba` package manager can install both. Modal provides a pre-built [Micromamba](https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html) base image that makes it easy to work with `micromamba`: ```python app = modal.App("bayes-pgm") numpyro_pymc_image = ( modal.Image.micromamba() .micromamba_install("pymc==5.10.4", "numpyro==0.13.2", channels=["conda-forge"]) ) @app.function(image=numpyro_pymc_image) def sample(): import pymc as pm import numpyro as np print(f"Running on PyMC v{pm.__version__} with JAX/numpyro v{np.__version__} backend") ... ``` ## Image caching and rebuilds Modal uses the definition of an Image to determine whether it needs to be rebuilt. If the definition hasn't changed since the last time you ran or deployed your App, the previous version will be pulled from the cache. Images are cached per layer (i.e., per `Image` method call), and breaking the cache on a single layer will cause cascading rebuilds for all subsequent layers. You can shorten iteration cycles by defining frequently-changing layers last so that the cached version of all other layers can be used. In some cases, you may want to force an Image to rebuild, even if the definition hasn't changed. You can do this by adding the `force_build=True` argument to any of the Image building methods. ```python image = ( modal.Image.debian_slim() .apt_install("git") .pip_install("slack-sdk", force_build=True) .run_commands("echo hi") ) ``` As in other cases where a layer's definition changes, both the `pip_install` and `run_commands` layers will rebuild, but the `apt_install` will not. Remember to remove `force_build=True` after you've rebuilt the Image, or it will rebuild every time you run your code. Alternatively, you can set the `MODAL_FORCE_BUILD` environment variable (e.g. `MODAL_FORCE_BUILD=1 modal run ...`) to rebuild all images attached to your App. But note that when you rebuild a base layer, the cache will be invalidated for _all_ Images that depend on it, and they will rebuild the next time you run or deploy any App that uses that base. If you're debugging an issue with your Image, a better option might be using `MODAL_IGNORE_CACHE=1`. This will rebuild the Image from the top without breaking the Image cache or affecting subsequent builds. ## Image builder updates Because changes to base images will cause cascading rebuilds, Modal is conservative about updating the base definitions that we provide. But many things are baked into these definitions, like the specific versions of the Image OS, the included Python, and the Modal client dependencies. We provide a separate mechanism for keeping base images up-to-date without causing unpredictable rebuilds: the "Image Builder Version". This is a workspace level-configuration that will be used for every Image built in your workspace. We release a new Image Builder Version every few months but allow you to update your workspace's configuration when convenient. After updating, your next deployment will take longer, because your Images will rebuild. You may also encounter problems, especially if your Image definition does not pin the version of the third-party libraries that it installs (as your new Image will get the latest version of these libraries, which may contain breaking changes). You can set the Image Builder Version for your workspace by going to your [workspace settings](https://modal.com/settings/image-builder-version). This page also documents the important updates in each version. #### Using existing container images # Using existing images This guide walks you through how to use an existing container image as a Modal Image. ```python notest sklearn_image = modal.Image.from_registry("huanjason/scikit-learn") custom_image = modal.Image.from_dockerfile("./src/Dockerfile") ``` ## Load an image from a public registry with `.from_registry` To load an image from a public registry, just pass the image name, including any tags, to [`Image.from_registry`](https://modal.com/docs/sdk/py/latest/Image#from_registry): ```python sklearn_image = modal.Image.from_registry("huanjason/scikit-learn") @app.function(image=sklearn_image) def fit_knn(): from sklearn.neighbors import KNeighborsClassifier ... ``` The `from_registry` method can load images from all public registries, such as [Nvidia's `nvcr.io`](https://catalog.ngc.nvidia.com/containers), [AWS ECR](https://aws.amazon.com/ecr/), and [GitHub's `ghcr.io`](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). You can further modify the image [just like any other Modal Image](https://modal.com/docs/guide/images): ```python continuation data_science_image = sklearn_image.uv_pip_install("polars", "datasette") ``` You can use external images so long as - The image is built for the [`linux/amd64` platform](https://unix.stackexchange.com/questions/53415/why-are-64-bit-distros-often-called-amd64) - The image has a [compatible `ENTRYPOINT`](#entrypoint) Additionally, to be used with a Modal Function, the image needs to have `python` and `pip` installed and available on the `$PATH`. If an existing image does not have either `python` or `pip` set up compatibly, you can still use it. Just provide a version number as the `add_python` argument to install a reproducible [standalone build](https://github.com/indygreg/python-build-standalone) of Python: ```python ubuntu_image = modal.Image.from_registry("ubuntu:22.04", add_python="3.11") valhalla_image = modal.Image.from_registry("gisops/valhalla:latest", add_python="3.12") ``` There are some additional restrictions for older versions of the Modal image builder. Image builder version is set at a workspace level via the settings page [here](https://modal.com/settings/image-builder-version). See the migration guides on that page for details on any additional restrictions on images. ## Load images from private registries You can also use images defined in private container registries on Modal. The exact method depends on the registry you are using. ### Docker Hub (Private) To pull container images from private Docker Hub repositories, [create an access token](https://docs.docker.com/security/for-developers/access-tokens/) with "Read-Only" permissions and use this token value and your Docker Hub username to create a Modal [Secret](https://modal.com/docs/guide/secrets). ``` REGISTRY_USERNAME=my-dockerhub-username REGISTRY_PASSWORD=dckr_pat_TS012345aaa67890bbbb1234ccc ``` Use this Secret with the [`modal.Image.from_registry`](https://modal.com/docs/sdk/py/latest/Image#from_registry) method. ### Elastic Container Registry (ECR) You can pull images from your AWS ECR account by specifying the full image URI as follows: ```python import modal aws_secret = modal.Secret.from_name("my-aws-secret") image = ( modal.Image.from_aws_ecr( "000000000000.dkr.ecr.us-east-1.amazonaws.com/my-private-registry:latest", secret=aws_secret, ) .pip_install("torch", "numpy", "huggingface") ) app = modal.App(image=image) ``` As shown above, you also need to use a [Modal Secret](https://modal.com/docs/guide/secrets) containing the environment variables `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION`. The AWS IAM user account associated with those keys must have access to the private registry you want to access. Alternatively, you can use [OIDC token authentication](https://modal.com/docs/guide/oidc-integration#pull-images-from-aws-elastic-container-registry-ecr). The user needs to have the following read-only policies: ```json { "Version": "2012-10-17", "Statement": [ { "Action": ["ecr:GetAuthorizationToken"], "Effect": "Allow", "Resource": "*" }, { "Effect": "Allow", "Action": [ "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:GetRepositoryPolicy", "ecr:DescribeRepositories", "ecr:ListImages", "ecr:DescribeImages", "ecr:BatchGetImage", "ecr:GetLifecyclePolicy", "ecr:GetLifecyclePolicyPreview", "ecr:ListTagsForResource", "ecr:DescribeImageScanFindings" ], "Resource": "" } ] } ``` You can use the IAM configuration above as a template for creating an IAM user. You can then [generate an access key](https://aws.amazon.com/premiumsupport/knowledge-center/create-access-key/) and create a Modal Secret using the AWS integration option. Modal will use your access keys to generate an ephemeral ECR token. That token is only used to pull image layers at the time a new image is built. We don't store this token but will cache the image once it has been pulled. Images on ECR must be private and follow [image configuration requirements](https://modal.com/docs/sdk/py/latest/Image#from_aws_ecr). ### Google Artifact Registry and Google Container Registry For further detail on how to pull images from Google's image registries, see [`modal.Image.from_gcp_artifact_registry`](https://modal.com/docs/sdk/py/latest/Image#from_gcp_artifact_registry). ### Azure Container Registry (ACR) Modal doesn't have native Azure support, but you can pull images from a private ACR using ACR's [token-based repository permissions](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-token-based-repository-permissions) to generate long-lived Docker credentials. Those credentials (token and password) can then be stored as a Modal Secret and used with [`modal.Image.from_registry`](https://modal.com/docs/sdk/py/latest/Image#from_registry) the same way as [Docker Hub private registry](#docker-hub-private) credentials. ## Bring your own image definition with `.from_dockerfile` You can define an Image from an existing Dockerfile by passing its path to [`Image.from_dockerfile`](https://modal.com/docs/sdk/py/latest/Image#from_dockerfile): ```python dockerfile_image = modal.Image.from_dockerfile("Dockerfile") @app.function(image=dockerfile_image) def fit(): import sklearn ... ``` Note that you can still extend this Image using image builder methods! See [the guide](https://modal.com/docs/guide/images) for details. ### Dockerfile command compatibility Since Modal doesn't use Docker to build containers, we have our own implementation of the [Dockerfile specification](https://docs.docker.com/engine/reference/builder/). Most Dockerfiles should work out of the box, but there are some differences to be aware of. First, a few minor Dockerfile commands and flags have not been implemented yet. These include `EXPOSE`, `HEALTHCHECK`, `LABEL`, `ONBUILD`, `STOPSIGNAL`, and `VOLUME`. Please reach out to us if your use case requires any of these. Next, there are some command-specific things that may be useful when porting a Dockerfile to Modal. #### `USER` Modal containers always run as root (uid 0). The [`USER`](https://docs.docker.com/engine/reference/builder/#user) instruction is ignored, whether it appears in your Dockerfile or is inherited from a base image pulled with [`Image.from_registry`](https://modal.com/docs/sdk/py/latest/Image#from_registry). To [reduce privileges](https://dwheeler.com/secure-programs/Secure-Programs-HOWTO/minimize-privileges.html) for programs running inside your Modal containers, use OS user management features like [`setuid`](https://man7.org/linux/man-pages/man2/setuid.2.html). For instance, in Python, you can pass in a `user`during [subprocess creation](https://docs.python.org/3/library/subprocess.html). #### `ENTRYPOINT` While the [`ENTRYPOINT`](https://docs.docker.com/engine/reference/builder/#entrypoint) command is supported, there is an additional constraint to the entrypoint script provided: when used with a Modal Function, it must also `exec` the arguments passed to it at some point. This is so the Modal Function runtime's Python entrypoint can run after your own. Most entrypoint scripts in Docker containers are wrappers over other scripts, so this is likely already the case. If you wish to write your own entrypoint script, you can use the following as a template: ```bash #!/usr/bin/env bash # Your custom startup commands here. exec "$@" # Runs the command passed to the entrypoint script. ``` If the above file is saved as `/usr/bin/my_entrypoint.sh` in your container, then you can register it as an entrypoint with `ENTRYPOINT ["/usr/bin/my_entrypoint.sh"]` in your Dockerfile, or with [`entrypoint`](https://modal.com/docs/sdk/py/latest/Image#entrypoint) as an Image build step. ```python import modal image = ( modal.Image.debian_slim() .pip_install("foo") .entrypoint(["/usr/bin/my_entrypoint.sh"]) ) ``` #### `ENV` We currently don't support default values in [interpolations](https://docs.docker.com/compose/compose-file/12-interpolation/), such as `${VAR:-default}` #### `ADD` `ADD` is limited to fetching from single URLs. Tar extraction, multiple URLs, and copy operations are currently not supported. #### Named images # Named images Named Images let you publish a Modal Image under a name that you can reference later to use the Image, akin to a container registry. This can be useful for stricter Image change management and for avoiding unintended Image invalidation and rebuilds on latency-sensitive code paths. Unlike inline Image definitions, referencing an image by name will never implicitly rebuild an Image. The Image reference for a name is mutable, and because the reference is typically updated only after a successful publish, callers keep using the previous working Image while the new build is running. A typical workflow using named images would be: 1. Define, build, and publish the Image in an independently run Image build script 2. Reference the published Image by name in Sandbox or Function code, getting the latest build of that image at the time ## Publishing an Image from a script Use [`Image.build`](https://modal.com/docs/sdk/py/latest/Image#build) to build the Image, then call `.publish()` on the resulting Image: {#snippet python()} ```python notest # build_image.py app = modal.App.lookup("image-builds", create_if_missing=True) image = ( modal.Image.debian_slim(python_version="3.12") .apt_install("git") .uv_pip_install("numpy", "pandas", "scikit-learn") .run_commands("python -c 'import sklearn; print(sklearn.__version__)'") ) with modal.enable_output(): image.build(app).publish("analytics-runtime") ``` {/snippet} {#snippet javascript()} ```javascript // build_image.ts const app = await modal.apps.fromName("image-builds", { createIfMissing: true, }); const image = modal.images .fromRegistry("python:3.12-slim") .dockerfileCommands([ "RUN apt-get update && apt-get install -y git", "RUN pip install numpy pandas scikit-learn", "RUN python -c 'import sklearn; print(sklearn.__version__)'", ]); const builtImage = await image.build(app); await builtImage.publish("analytics-runtime"); ``` {/snippet} {#snippet go()} ```go // build_image.go app, err := mc.Apps.FromName(ctx, "image-builds", &modal.AppFromNameParams{ CreateIfMissing: true, }) image := mc.Images.FromRegistry("python:3.12-slim", nil). DockerfileCommands([]string{ "RUN apt-get update && apt-get install -y git", "RUN pip install numpy pandas scikit-learn", "RUN python -c 'import sklearn; print(sklearn.__version__)'", }, nil) builtImage, err := image.Build(ctx, app, nil) err = builtImage.Publish(ctx, "analytics-runtime", nil) ``` {/snippet} ## Starting Sandboxes using named Images Named Images are especially useful for Sandboxes because Sandbox creation often happens on a latency-sensitive path and you typically never want to block Sandbox creation on rebuilding an Image. Use [`Image.from_name`](https://modal.com/docs/sdk/py/latest/Image#from_name) when referencing a named Image that you have previously built, and start the Sandbox using that: {#snippet python()} ```python notest # sandbox_launcher.py sb = modal.Sandbox.create( "python", "-c", "import pandas, sklearn; print('ready')", image=modal.Image.from_name("analytics-runtime"), app=app, ) print(sb.stdout.read()) ``` {/snippet} {#snippet javascript()} ```javascript // sandbox_launcher.ts const image = await modal.images.fromName("analytics-runtime"); const sb = await modal.sandboxes.create(app, image); const p = await sb.exec([ "python", "-c", "import pandas, sklearn; print('ready')", ]); console.log(await p.stdout.readText()); sb.detach(); ``` {/snippet} {#snippet go()} ```go // sandbox_launcher.go image, err := mc.Images.FromName(ctx, "analytics-runtime", nil) sb, err := mc.Sandboxes.Create(ctx, app, image, nil) defer sb.Detach() p, err := sb.Exec(ctx, []string{ "python", "-c", "import pandas, sklearn; print('ready')", }, nil) stdout, err := io.ReadAll(p.Stdout) fmt.Println(string(stdout)) ``` {/snippet} ## Running Functions using named Images Named Images can also be used when defining Modal Functions when you want more control over when a Function starts using a new Image. To use a named Image, point the Function image attribute to a [`Image.from_name`](https://modal.com/docs/sdk/py/latest/Image#from_name) reference: ```python notest # app.py @app.function(image=modal.Image.from_name("analytics-runtime")) def train(): import pandas as pd from sklearn.linear_model import LinearRegression ... ``` Note that publishing a new version of this named Image would not automatically update your deployed Functions to use the updated Image. You still need to redeploy the App that references that name for the change to propagate. ## Tags Every named Image is represented using a `{name}:{tag}` name - if you do not specify the tag part, the `:latest` tag is automatically used. You can publish the same Image using multiple names or tag which can be useful to do things like versioning of images. #### Fast pull from registry # Fast pull from registry The performance of pulling public and private images from registries into Modal can be significantly improved by adopting the [eStargz](https://github.com/containerd/stargz-snapshotter/blob/main/docs/estargz.md) compression format. By applying eStargz compression during your image build and push, Modal will be much more efficient at pulling down your image from the registry. ## How to use estargz If you have [Buildkit](https://docs.docker.com/build/buildkit/) version greater than `0.10.0`, adopting `estargz` is as simple as adding some flags to your `docker buildx build` command: - `type=registry` flag will instruct BuildKit to push the image after building. - If you do not push the image from immediately after build and instead attempt to push it later with docker push, the image will be converted to a standard gzip image. - `compression=estargz` specifies that we are using the [eStargz](https://github.com/containerd/stargz-snapshotter/blob/main/docs/estargz.md) compression format. - `oci-mediatypes=true` specifies that we are using the OCI media types, which is required for eStargz. - `force-compression=true` will recompress the entire image and convert the base image to eStargz if it is not already. ```bash docker buildx build --tag "//:" \ --output type=registry,compression=estargz,force-compression=true,oci-mediatypes=true \ . ``` Then reference the container image as normal in your Modal code. ```python notest app = modal.App( "example-estargz-pull", image=modal.Image.from_registry( "public.ecr.aws/modal/estargz-example-images:text-generation-v1-esgz" ) ) ``` At build time you should see the eStargz-enabled puller activate: ``` Building image im-TinABCTIf12345ydEwTXYZ => Step 0: FROM public.ecr.aws/modal/estargz-example-images:text-generation-v1-esgz Using estargz to speed up image pull (index loaded in 1.86s)... Progress: 10% complete... (1.11s elapsed) Progress: 20% complete... (3.10s elapsed) Progress: 30% complete... (4.18s elapsed) Progress: 40% complete... (4.76s elapsed) Progress: 50% complete... (5.51s elapsed) Progress: 62% complete... (6.17s elapsed) Progress: 74% complete... (6.99s elapsed) Progress: 81% complete... (7.23s elapsed) Progress: 99% complete... (8.90s elapsed) Progress: 100% complete... (8.90s elapsed) Copying image... Copied image in 5.81s ``` ## Supported registries Currently, Modal supports fast estargz pulling images with the following registries: - AWS Elastic Container Registry (ECR) - Docker Hub (docker.io) - Google Artifact Registry (gcr.io, pkg.dev) We are working on adding support for GitHub Container Registry (ghcr.io). ### GPUs and other resources #### GPU acceleration # GPU acceleration Modal makes it easy to run your code on [GPUs](https://modal.com/gpu-glossary/readme). ## Quickstart Here's a simple example of a Function running on an A100 in Modal: ```python import modal image = modal.Image.debian_slim().pip_install("torch", "numpy") app = modal.App(image=image) @app.function(gpu="A100") def run(): import torch assert torch.cuda.is_available() ``` ## Specifying GPU type You can pick a specific GPU type for your Function via the `gpu` argument. Modal supports the following values for this parameter: - `T4` - `L4` - `A10` - `L40S` - `A100` - `A100-40GB` - `A100-80GB` - `RTX-PRO-6000` - `H100`/`H100!` - `H200` - `B200`/`B200+` - `B300` For instance, to use a B200, you can use `@app.function(gpu="B200")`. Refer to our [pricing page](https://modal.com/pricing) for the latest pricing on each GPU type. ## Specifying GPU count You can specify more than 1 GPU per container by appending `:n` to the GPU argument. For instance, to run a Function with eight H100s: ```python @app.function(gpu="H100:8") def run_llama_405b_fp8(): ... ``` Currently B300, B200, H200, H100, A100, L4, T4 and L40S instances support up to 8 GPUs (up to 2,304 GB GPU RAM), and A10 instances support up to 4 GPUs (up to 96 GB GPU RAM). Note that requesting more than 2 GPUs per container will usually result in larger wait times. These GPUs are always attached to the same physical machine. ## Picking a GPU For running, rather than training, neural networks, we recommend starting off with the [L40S](https://resources.nvidia.com/en-us-l40s/l40s-datasheet-28413), which offers an excellent trade-off of cost and performance and 48 GB of GPU RAM for storing model weights and activations. For more on how to pick a GPU for use with neural networks like LLaMA or Stable Diffusion, and for tips on how to make that GPU go brrr, check out [Tim Dettemers' blog post](https://timdettmers.com/2023/01/30/which-gpu-for-deep-learning/) or the [Full Stack Deep Learning page on Cloud GPUs](https://fullstackdeeplearning.com/cloud-gpus/). ## B300 GPUs [B300s](https://www.nvidia.com/en-us/data-center/dgx-b300/) are NVIDIA Blackwell Ultra GPUs, based on the Blackwell [architecture](https://modal.com/gpu-glossary/device-hardware/streaming-multiprocessor-architecture). To request a B300, set the `gpu` argument to `"B300"`: ```python @app.function(gpu="B300:8") def run_inference(): ... ``` B300 requires CUDA version 13.1+. Make sure your container Image and libraries are compatible with CUDA 13 before requesting a B300. ## B200 GPUs B200s are [NVIDIA data center GPUs](https://www.nvidia.com/en-us/data-center/dgx-b200/) based on the Blackwell [architecture](https://modal.com/gpu-glossary/device-hardware/streaming-multiprocessor-architecture). To request a B200, set the `gpu` argument to `"B200"` ```python @app.function(gpu="B200:8") def run_deepseek(): ... ``` Check out [this example](https://modal.com/docs/examples/llm_inference) to see how you can use B200s to max out vLLM serving performance for LLaMA 3.1-8B. Before you jump for this powerful GPU, make sure you understand where the bottlenecks are in your computations. For example, running language models with small batch sizes (e.g. one prompt at a time) results in a [bottleneck on memory, not arithmetic](https://kipp.ly/transformer-inference-arithmetic/). Since arithmetic throughput has risen faster than memory throughput in recent hardware generations, speedups for memory-bound GPU jobs are not as extreme and may not be worth the extra cost. ### Opt-in upgrade to B300 Use `gpu="B200+"` to allow Modal to run requests on either B200 or B300 GPUs. B200+ is billed as B200, regardless of which GPU is used. Use this option only if your code is compatible with both types of GPUs. B300 requires CUDA version 13.1+. Use this to have access to a greater capacity pool automatically. ## H200 and H100 GPUs [H200s](https://www.nvidia.com/en-us/data-center/h200/) and [H100s](https://www.nvidia.com/en-us/data-center/h100/) are the previous generation of top-of-the-line data center chips from NVIDIA, based on the Hopper [architecture](https://modal.com/gpu-glossary/device-hardware/streaming-multiprocessor-architecture). These GPUs have better software support than do Blackwell GPUs (e.g. popular libraries include pre-compiled kernels for Hopper, but not Blackwell), and they often get the job done at a competitive cost, so they are a common choice of accelerator, on and off Modal. All H100 and H200 GPUs on the Modal platform are of the SXM variant, as can be verified by examining the [power draw](https://modal.com/docs/guide/gpu-metrics) in the dashboard or with `nvidia-smi`. ### Automatic upgrades to H200s Modal may automatically upgrade a `gpu="H100"` request to run on an H200. This automatic upgrade does _not_ change the cost of the GPU. Kernels [compatible](https://modal.com/gpu-glossary/device-software/compute-capability) with H200s are also compatible with H100s, so your code will still run, just faster, so long as it doesn't make strict assumptions about memory capacity. An H200’s [HBM3e memory](https://modal.com/gpu-glossary/device-hardware/gpu-ram) has a capacity of 141 GB and a bandwidth of 4.8TB/s, 1.75x larger and 1.4x faster than an NVIDIA H100 with HBM3. In cases where an automatic upgrade to H200 would not be helpful (for instance, benchmarking) you can pass `gpu=H100!` to avoid it. ## A100 GPUs [A100s](https://www.nvidia.com/en-us/data-center/a100/) are based on NVIDIA's Ampere [architecture](https://modal.com/gpu-glossary/device-hardware/streaming-multiprocessor-architecture). Modal offers two versions of the A100: one with 40 GB of RAM and another with 80 GB of RAM. To request an A100 with 40 GB of [GPU memory](https://modal.com/gpu-glossary/device-hardware/gpu-ram), use `gpu="A100"`: ```python @app.function(gpu="A100") def qwen_7b(): ... ``` Modal may automatically upgrade a `gpu="A100"` request to run on an 80 GB A100. This automatic upgrade does _not_ change the cost of the GPU. You can specifically request a 40GB A100 with the string `A100-40GB`. To specifically request an 80 GB A100, use the string `A100-80GB`: ```python @app.function(gpu="A100-80GB") def llama_70b_fp8(): ... ``` ## GPU fallbacks Modal allows specifying a list of possible GPU types, suitable for Functions that are compatible with multiple options. Modal respects the ordering of this list and will try to allocate the most preferred GPU type before falling back to less preferred ones. ```python @app.function(gpu=["H100", "A100-40GB:2"]) def run_on_80gb(): ... ``` See [this example](https://modal.com/docs/examples/gpu_fallbacks) for more detail. ## Multi GPU training Modal currently supports multi-GPU training on a single node, with multi-node training in private Beta (email us at support@modal.com for access). Depending on which framework you are using, you may need to use different techniques to train on multiple GPUs. If the framework re-executes the entrypoint of the Python process (like [PyTorch Lightning](https://lightning.ai/docs/pytorch/stable/index.html)) you need to either set the strategy to `ddp_spawn` or `ddp_notebook` if you wish to invoke the training directly. Another option is to run the training script as a subprocess instead. ```python @app.function(gpu="A100:2") def run(): import subprocess import sys subprocess.run( ["python", "train.py"], stdout=sys.stdout, stderr=sys.stderr, check=True, ) ``` ## Examples and more resources For more information about GPUs in general, check out our [GPU Glossary](https://modal.com/gpu-glossary/readme). Or take a look some examples of Modal Apps using GPUs: - [Fine-tune a character LoRA for your pet](https://modal.com/docs/examples/diffusers_lora_finetune) - [Fast LLM inference on big GPUs](https://modal.com/docs/examples/llm_inference) - [Stable Diffusion with a CLI, API, and web UI](https://modal.com/docs/examples/text_to_image) - [Rendering Blender videos](https://modal.com/docs/examples/blender_video) #### Using CUDA on Modal # Using CUDA on Modal Modal makes it easy to accelerate your workloads with datacenter-grade NVIDIA GPUs. To take advantage of the hardware, you need to use matching software: the CUDA stack. This guide explains the components of that stack and how to install them on Modal. For more on which GPUs are available on Modal and how to choose a GPU for your use case, see [this guide](https://modal.com/docs/guide/gpu). For a deep dive on both the [GPU hardware](https://modal.com/gpu-glossary/device-hardware) and [software](https://modal.com/gpu-glossary/device-software) and for even more detail on [the CUDA stack](https://modal.com/gpu-glossary/host-software/), see our [GPU Glossary](https://modal.com/gpu-glossary/readme). Here's the tl;dr: - The [NVIDIA Accelerated Graphics Driver for Linux-x86_64](https://docs.nvidia.com/cuda/cuda-installation-guide-linux/#driver-installation), version 580.95.05, and [CUDA Driver API](https://docs.nvidia.com/cuda/archive/13.0.0/cuda-driver-api/index.html), version 13.0, are already installed. You can call `nvidia-smi` or run compiled CUDA programs from any Modal Function with access to a GPU. - That means you can install many popular libraries like `torch` that bundle their other CUDA dependencies [with a simple `pip_install`](#install-gpu-accelerated-torch-and-transformers-with-pip_install). - For bleeding-edge libraries like `flash-attn`, you may need to install CUDA dependencies manually. To make your life easier, [use an existing image](#for-more-complex-setups-use-an-officially-supported-cuda-image). ## What is CUDA? When someone refers to "installing CUDA" or "using CUDA", they are referring not to a library, but to a [stack](https://modal.com/gpu-glossary/host-software/cuda-software-platform) with multiple layers. Your application code (and its dependencies) can interact with the stack at different levels. ![The CUDA stack](../../assets/docs/cuda-stack-diagram.png) This leads to a lot of confusion. To help clear that up, the following sections explain each component in detail. ### Level 0: Kernel-mode driver components At the lowest level are the [_kernel-mode driver components_](https://docs.nvidia.com/cuda/cuda-installation-guide-linux/#nvidia-open-gpu-kernel-modules). The Linux kernel is essentially a single program operating the entire machine and all of its hardware. To add hardware to the machine, this program is extended by loading new modules into it. These components communicate directly with hardware -- in this case the GPU. Because they are kernel modules, these driver components are tightly integrated with the host operating system that runs your containerized Modal Functions and are not something you can inspect or change yourself. ### Level 1: User-mode driver API All action in Linux that doesn't occur in the kernel occurs in [user space](https://en.wikipedia.org/wiki/User_space). To talk to the kernel drivers from our user space programs, we need _user-mode driver components_. Most prominently, that includes: - the [CUDA Driver API](https://modal.com/gpu-glossary/host-software/cuda-driver-api), a [shared object](https://en.wikipedia.org/wiki/Shared_library) called `libcuda.so`. This object exposes functions like [`cuMemAlloc`](https://docs.nvidia.com/cuda/archive/12.8.0/cuda-driver-api/group__CUDA__MEM.html#group__CUDA__MEM_1gb82d2a09844a58dd9e744dc31e8aa467), for allocating GPU memory. - the [NVIDIA management library](https://developer.nvidia.com/management-library-nvml), `libnvidia-ml.so`, and its command line interface [`nvidia-smi`](https://developer.nvidia.com/system-management-interface). You can use these tools to check the status of the system's GPU(s). These components are installed on all Modal machines with access to GPUs. Because they are user-level components, you can use them directly: ```python runner:ModalRunner import modal app = modal.App() @app.function(gpu="any") def check_nvidia_smi(): import subprocess output = subprocess.check_output(["nvidia-smi"], text=True) assert "Driver Version:" in output assert "CUDA Version:" in output print(output) return output ``` ### Level 2: CUDA Toolkit Wrapping the CUDA Driver API is the [CUDA Runtime API](https://modal.com/gpu-glossary/host-software/cuda-runtime-api), the `libcudart.so` shared library. This API includes functions like [`cudaLaunchKernel`](https://docs.nvidia.com/cuda/archive/12.8.0/cuda-runtime-api/group__CUDART__HIGHLEVEL.html#group__CUDART__HIGHLEVEL_1g7656391f2e52f569214adbfc19689eb3) and is more commonly used in CUDA programs (see [this HackerNews comment](https://news.ycombinator.com/item?id=20616385) for color commentary on why). This shared library is _not_ installed by default on Modal. The CUDA Runtime API is generally installed as part of the larger [NVIDIA CUDA Toolkit](https://docs.nvidia.com/cuda/index.html), which includes the [NVIDIA CUDA compiler driver](https://modal.com/gpu-glossary/host-software/nvcc) (`nvcc`) and its toolchain and a number of [useful goodies](https://modal.com/gpu-glossary/host-software/cuda-binary-utilities) for writing and debugging CUDA programs (`cuobjdump`, `cudnn`, profilers, etc.). Contemporary GPU-accelerated machine learning workloads like LLM inference frequently make use of many components of the CUDA Toolkit, such as the run-time compilation library [`nvrtc`](https://docs.nvidia.com/cuda/archive/12.8.0/nvrtc/index.html). So why aren't these components installed along with the drivers? A compiled CUDA program can run without the CUDA Runtime API installed on the system, by [statically linking](https://en.wikipedia.org/wiki/Static_library) the CUDA Runtime API into the program binary, though this is fairly uncommon for CUDA-accelerated Python programs. Additionally, older versions of these components are needed for some applications and some application deployments even use several versions at once. Both patterns are compatible with the host machine driver provided on Modal. ## Install GPU-accelerated `torch` and `transformers` with `pip_install` The components of the CUDA Toolkit can be installed via `pip`, via PyPI packages like [`nvidia-cuda-runtime-cu12`](https://pypi.org/project/nvidia-cuda-runtime-cu12/) and [`nvidia-cuda-nvrtc-cu12`](https://pypi.org/project/nvidia-cuda-nvrtc-cu12/). These components are listed as dependencies of some popular GPU-accelerated Python libraries, like `torch`. Because Modal already includes the lower parts of the CUDA stack, you can install these libraries with [the `pip_install` method of `modal.Image`](https://modal.com/docs/guide/images#add-python-packages-with-pip_install), just like any other Python library: ```python image = modal.Image.debian_slim().pip_install("torch") @app.function(gpu="any", image=image) def run_torch(): import torch has_cuda = torch.cuda.is_available() print(f"It is {has_cuda} that torch can access CUDA") return has_cuda ``` Many libraries for running open-weights models, like `transformers` and `vllm`, use `torch` under the hood and so can be installed in the same way: ```python image = modal.Image.debian_slim().pip_install("transformers[torch]") image = image.apt_install("ffmpeg") # for audio processing @app.function(gpu="any", image=image) def run_transformers(): from transformers import pipeline transcriber = pipeline(model="openai/whisper-tiny.en", device="cuda") result = transcriber("https://modal-cdn.com/mlk.flac") print(result["text"]) # I have a dream that one day this nation will rise up live out the true meaning of its creed ``` ## For more complex setups, use an officially-supported CUDA image The disadvantage of installing the CUDA stack via `pip` is that many other libraries that depend on its components being installed as normal system packages cannot find them. For these cases, we recommend you use an image that already has the full CUDA stack installed as system packages and all environment variables set correctly, like the [`nvidia/cuda:*-devel-*` images on Docker Hub](https://hub.docker.com/r/nvidia/cuda). [TensorRT-LLM](https://nvidia.github.io/TensorRT-LLM/overview.html) is an inference engine that accelerates and optimizes performance for the large language models. It requires the full CUDA toolkit for installation. ```python cuda_version = "12.8.1" # should be no greater than host CUDA version flavor = "devel" # includes full CUDA toolkit operating_sys = "ubuntu24.04" tag = f"{cuda_version}-{flavor}-{operating_sys}" HF_CACHE_PATH = "/cache" image = ( modal.Image.from_registry(f"nvidia/cuda:{tag}", add_python="3.12") .entrypoint([]) # remove verbose logging by base image on entry .apt_install("libopenmpi-dev") # required for tensorrt .pip_install("tensorrt-llm==0.19.0", "pynvml", extra_index_url="https://pypi.nvidia.com") .pip_install("hf-transfer", "huggingface_hub[hf_xet]") .env({"HF_HUB_CACHE": HF_CACHE_PATH, "HF_HUB_ENABLE_HF_TRANSFER": "1", "PMIX_MCA_gds": "hash"}) ) app = modal.App("tensorrt-llm", image=image) hf_cache_volume = modal.Volume.from_name("hf_cache_tensorrt", create_if_missing=True) @app.function(gpu="A10G", volumes={HF_CACHE_PATH: hf_cache_volume}) def run_tiny_model(): from tensorrt_llm import LLM, SamplingParams sampling_params = SamplingParams(temperature=0.8, top_p=0.95) llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0") output = llm.generate("The capital of France is", sampling_params) print(f"Generated text: {output.outputs[0].text}") return output.outputs[0].text ``` Make sure to choose a version of CUDA that is no greater than the version provided by the host machine. Older versions in the `12.*` and `13.*` series are guaranteed to be compatible with the host machine's driver, but older major versions (`11.*`, `10.*`, etc.) may not be. ## What next? For more on accessing and choosing GPUs on Modal, check out [this guide](https://modal.com/docs/guide/gpu). To dive deep on GPU internals, check out our [GPU Glossary](https://modal.com/gpu-glossary/readme). To see these installation patterns in action, check out these examples: - [Fast LLM inference on big GPUs](https://modal.com/docs/examples/llm_inference) - [Finetune a character LoRA for your pet](https://modal.com/docs/examples/diffusers_lora_finetune) - [Optimized Flux inference](https://modal.com/docs/examples/flux) #### Configuring CPU, memory, and disk # Configuring CPU, memory, and disk Each Modal Function or Sandbox container has a default request of 0.125 CPU cores and 128 MiB of memory. Containers can exceed this minimum if the worker has available CPU or memory. You can also guarantee access to more resources by requesting larger values, [similarly to Kubernetes](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/). This guide covers resource configuration for both [Functions](https://modal.com/docs/guide/apps#apps-functions-and-entrypoints) and [Sandboxes](https://modal.com/docs/guide/sandboxes). For Sandbox-specific guidance on pricing and cost optimization, see [Sandbox pricing and resources](https://modal.com/docs/guide/sandbox-resources). ## CPU cores If you have code that must run on a larger number of cores, you can request that using the `cpu` argument. This allows you to specify a floating-point number of CPU cores: ```python import modal app = modal.App() @app.function(cpu=8.0) def my_function(): # code here will have access to at least 8.0 cores ... ``` Note that this value corresponds to physical cores, not vCPUs. Modal also will set several environment variables that control multi-threading behavior in linear algebra and inference libraries (e.g., `OPENBLAS_NUM_THREADS`, `OMP_NUM_THREADS`, `MKL_NUM_THREADS`, `ORT_INTRA_OP_NUM_THREADS`) based on your CPU request. ## Memory If you have code that needs more guaranteed memory, you can request it using the `memory` argument. This expects an integer number of megabytes: ```python import modal app = modal.App() @app.function(memory=32768) def my_function(): # code here will have access to at least 32 GiB of RAM ... ``` ## How much can I request? For both CPU and memory, a maximum is enforced at Function or Sandbox creation time to ensure your containers can be scheduled for execution. Requests exceeding the maximum will be rejected with an [`InvalidError`](https://modal.com/docs/sdk/py/latest/exception#invaliderror). ## Billing For CPU and memory, you'll be charged based on whichever is higher: your request or actual usage. Disk requests are billed by increasing the memory request at a 20:1 ratio. For example, requesting 500 GiB of disk will increase the memory request to 25 GiB, if it is not already set higher. ## Resource limits ### CPU limits Modal containers have a default soft CPU limit that is set at 16 physical cores above the CPU request. Given that the default CPU request is 0.125 cores, the default soft CPU limit is 16.125 cores. Above this limit, the host will begin to throttle the CPU usage of the container. You can alternatively set the CPU limit explicitly: ```python cpu_request = 1.0 cpu_limit = 4.0 @app.function(cpu=(cpu_request, cpu_limit)) def f(): ... ``` ### Memory limits Modal containers can have a hard memory limit which will 'Out of Memory' (OOM) kill containers which attempt to exceed the limit. This functionality is useful when a process has a serious memory leak. You can set the limit and have the container killed to avoid paying for the leaked GBs of memory. Specify this limit using the `memory` parameter on [`@app.function()`](https://modal.com/docs/sdk/py/latest/App#function) or [`Sandbox.create()`](https://modal.com/docs/sdk/py/latest/Sandbox#create): ```python mem_request = 1024 mem_limit = 2048 @app.function( memory=(mem_request, mem_limit), ) def f(): ... ``` ### Disk limits Running Modal containers have access to many GBs of SSD disk, but the amount of writes is limited by: 1. The size of the underlying worker's SSD disk capacity 2. A per-container disk quota that defaults to 512 GiB. Hitting either limit will cause the container's disk writes to be rejected, which typically manifests as an `OSError`. Increased disk sizes can be requested with the `ephemeral_disk` parameter on [`@app.function()`](https://modal.com/docs/sdk/py/latest/App#function). The maximum disk size is 3.0 TiB (3,145,728 MiB). Larger disks are intended to be used for [dataset processing](https://modal.com/docs/guide/dataset-ingestion). ### Scaling out #### Scaling out # Scaling out Modal makes it easy to scale compute across thousands of containers. You won't have to worry about your App crashing if it goes viral or need to wait a long time for your batch jobs to complete. For the the most part, scaling out will happen automatically, and you won't need to think about it. But it can be helpful to understand how Modal's autoscaler works and how you can control its behavior when you need finer control. ## How does autoscaling work on Modal? Every Modal Function corresponds to an autoscaling pool of containers. The size of the pool is managed by Modal's autoscaler. The autoscaler will spin up new containers when there is no capacity available for new inputs, and it will spin down containers when resources are idling. By default, Modal Functions will scale to zero when there are no inputs to process. Autoscaling decisions are made quickly and frequently so that your batch jobs can ramp up fast and your deployed Apps can respond to any sudden changes in traffic. ## Configuring autoscaling behavior Modal exposes a few settings that allow you to configure the autoscaler's behavior. These settings can be passed to the `@app.function` or `@app.cls` decorators: - `max_containers`: The upper limit on containers for the specific Function. - `min_containers`: The minimum number of containers that should be kept warm, even when the Function is inactive. - `buffer_containers`: The size of the buffer to maintain while the Function is active, so that additional inputs will not need to queue for a new container. - `scaledown_window`: The maximum duration (in seconds) that individual containers can remain idle when scaling down. In general, these settings allow you to trade off cost and latency. Maintaining a larger warm pool or idle buffer will increase costs but reduce the chance that inputs will need to wait for a new container to start. Similarly, a longer scaledown window will let containers idle for longer, which might help avoid unnecessary churn for Apps that receive regular but infrequent inputs. Note that containers may not wait for the entire scaledown window before shutting down if the App is substantially overprovisioned. ## Dynamic autoscaler updates It's also possible to update the autoscaler settings dynamically (i.e., without redeploying the App) using the [`Function.update_autoscaler()`](https://modal.com/docs/sdk/py/latest/Function#update_autoscaler) method: ```python notest f = modal.Function.from_name("my-app", "f") f.update_autoscaler(max_containers=100) ``` The autoscaler settings will revert to the configuration in the function decorator the next time you deploy the App. Or they can be overridden by further dynamic updates: ```python notest f.update_autoscaler(min_containers=2, max_containers=10) f.update_autoscaler(min_containers=4) # max_containers=10 will still be in effect ``` A common pattern is to run this method in a [scheduled function](https://modal.com/docs/guide/cron) that adjusts the size of the warm pool (or container buffer) based on the time of day: ```python @app.function() def inference_server(): ... @app.function(schedule=modal.Cron("0 6 * * *", timezone="America/New_York")) def increase_warm_pool(): inference_server.update_autoscaler(min_containers=4) @app.function(schedule=modal.Cron("0 22 * * *", timezone="America/New_York")) def decrease_warm_pool(): inference_server.update_autoscaler(min_containers=0) ``` When you have a [`modal.Cls`](https://modal.com/docs/sdk/py/latest/Cls), `update_autoscaler` is a method on an _instance_ and will control the autoscaling behavior of containers serving the Function with that specific set of parameters: ```python notest MyClass = modal.Cls.from_name("my-app", "MyClass") obj = MyClass(model_version="3.5") obj.update_autoscaler(buffer_containers=2) # type: ignore ``` Note that it's necessary to disable type checking on this line, because the object will appear as an instance of the class that you defined rather than the Modal wrapper type. ## Parallel execution of inputs If your code is running the same function repeatedly with different independent inputs (e.g., a grid search), the easiest way to increase performance is to run those function calls in parallel using Modal's [`Function.map()`](https://modal.com/docs/sdk/py/latest/Function#map) method. Here is an example if we had a function `evaluate_model` that takes a single argument: ```python import modal app = modal.App() @app.function() def evaluate_model(x): ... @app.local_entrypoint() def main(): inputs = list(range(100)) for result in evaluate_model.map(inputs): # runs many inputs in parallel ... ``` In this example, `evaluate_model` will be called with each of the 100 inputs (the numbers 0 - 99 in this case) roughly in parallel and the results are returned as an iterable with the results ordered in the same way as the inputs. ### Exceptions By default, if any of the function calls raises an exception, the exception will be propagated. To treat exceptions as successful results and aggregate them in the results list, pass in [`return_exceptions=True`](https://modal.com/docs/sdk/py/latest/Function#map). ```python @app.function() def my_func(a): if a == 2: raise Exception("ohno") return a ** 2 @app.local_entrypoint() def main(): print(list(my_func.map(range(3), return_exceptions=True))) # [0, 1, Exception('ohno'))] ``` ### Starmap If your function takes multiple variable arguments, you can either use [`Function.map()`](https://modal.com/docs/sdk/py/latest/Function#map) with one input iterator per argument, or [`Function.starmap()`](https://modal.com/docs/sdk/py/latest/Function#starmap) with a single input iterator containing sequences (like tuples) that can be spread over the arguments. This works similarly to Python's built in `map` and `itertools.starmap`. ```python @app.function() def my_func(a, b): return a + b @app.local_entrypoint() def main(): assert list(my_func.starmap([(1, 2), (3, 4)])) == [3, 7] ``` ### Gotchas Note that `.map()` is a method on the modal function object itself, so you don't explicitly _call_ the function. Incorrect usage: ```python notest results = evaluate_model(inputs).map() ``` Modal's map is also not the same as using Python's builtin `map()`. While the following will technically work, it will execute all inputs in sequence rather than in parallel. Incorrect usage: ```python notest results = map(evaluate_model, inputs) ``` ## Asynchronous usage All Modal APIs are available in both blocking and asynchronous variants. If you are comfortable with asynchronous programming, you can use it to create arbitrary parallel execution patterns, with the added benefit that any Modal functions will be executed remotely. See the [async guide](https://modal.com/docs/guide/async) or the examples for more information about asynchronous usage. ## GPU acceleration Sometimes you can speed up your applications by utilizing GPU acceleration. See the [GPU section](https://modal.com/docs/guide/gpu) for more information. ## Scaling Limits Modal enforces various platform limits that affect scalability. Limits on the total number of concurrent containers (and the total number of GPUs in concurrent use) depend on your workspace's [plan level](https://modal.com/pricing). There is also a hard limit of 4,000 concurrent containers running for a single Function. Other limits apply at the level of individual inputs and depend on the specific Function [invocation method](https://modal.com/docs/guide/function-invocation-methods). #### Input concurrency # Input concurrency This guide documents the use of the `modal.concurrent` decorator to process multiple inputs at the same time in a single Modal container. This page is a high-level guide to input concurrency. For reference documentation of the `modal.concurrent` decorator, see [this page](https://modal.com/docs/sdk/py/latest/concurrent). ## Overview As traffic to your application increases, Modal will automatically scale up the number of containers running your Function:
By default, each container will be assigned one input at a time. Autoscaling across containers allows your Function to process inputs in parallel. This is ideal when the operations performed by your Function are CPU-bound. For some workloads, though, it is inefficient for containers to process inputs one-by-one. Modal supports these workloads with its _input concurrency_ feature, which allows individual containers to process multiple inputs at the same time:
When used effectively, input concurrency can reduce latency and lower costs. ## Use cases Input concurrency can be especially effective for workloads that are primarily I/O-bound, e.g.: - Querying a database - Making external API requests - Making remote calls to other Modal Functions For such workloads, individual containers may be able to concurrently process large numbers of inputs with minimal additional latency. This means that your Modal application will be more efficient overall, as it won't need to scale containers up and down as traffic ebbs and flows. Another use case is to leverage _continuous batching_ on GPU-accelerated containers. Frameworks such as [vLLM](https://modal.com/docs/examples/llm_inference) can achieve the benefits of batching across multiple inputs even when those inputs do not arrive simultaneously (because new batches are formed for each forward pass of the model). Note that for CPU-bound workloads, input concurrency will likely not be as effective (or will even be counterproductive), and you may want to use Modal's [_dynamic batching_ feature](https://modal.com/docs/guide/dynamic-batching) instead. ## Enabling input concurrency To enable input concurrency, add the `@modal.concurrent` decorator: ```python @app.function() @modal.concurrent(max_inputs=100) def my_function(input: str): ... ``` When using the class pattern, the decorator should be applied at the level of the _class_, not on individual methods: ```python @app.cls() @modal.concurrent(max_inputs=100) class MyCls: @modal.method() def my_method(self, input: str): ... ``` Because all methods on a class will be served by the same containers, a class with input concurrency enabled will concurrently run distinct methods in addition to multiple inputs for the same method. ## Setting a concurrency target When using the `@modal.concurrent` decorator, you must always configure the maximum number of inputs that each container will concurrently process. If demand exceeds this limit, Modal will automatically scale up more containers. Additional inputs may need to queue up while these additional containers cold start. To help avoid degraded latency during scaleup, the `@modal.concurrent` decorator has a separate `target_inputs` parameter. When set, Modal's autoscaler will aim for this target as it provisions resources. If demand increases faster than new containers can spin up, the active containers will be allowed to burst above the target up to the `max_inputs` limit: ```python @app.function() @modal.concurrent(max_inputs=96, target_inputs=80) # Allow a 20% burst def my_function(input: str): ... ``` It may take some experimentation to find the right settings for these parameters in your particular application. Our suggestion is to set the `target_inputs` based on your desired latency and the `max_inputs` based on resource constraints (i.e., to avoid GPU OOM). You may also consider the relative latency cost of scaling up a new container versus overloading the existing containers. ## Concurrency mechanisms Modal uses different concurrency mechanisms to execute your Function depending on whether it is defined as synchronous or asynchronous. Each mechanism imposes certain requirements on the Function implementation. Input concurrency is an advanced feature, and it's important to make sure that your implementation complies with these requirements to avoid unexpected behavior. For synchronous Functions, Modal will execute concurrent inputs on separate threads. _This means that the Function implementation must be thread-safe._ ```python # Each container can execute up to 10 inputs in separate threads @app.function() @modal.concurrent(max_inputs=10) def sleep_sync(): # Function must be thread-safe time.sleep(1) ``` For asynchronous Functions, Modal will execute concurrent inputs using separate `asyncio` tasks on a single thread. This does not require thread safety, but it does mean that the Function needs to participate in collaborative multitasking (i.e., it should not block the event loop). ```python # Each container can execute up to 10 inputs with separate async tasks @app.function() @modal.concurrent(max_inputs=10) async def sleep_async(): # Function must not block the event loop await asyncio.sleep(1) ``` ## Gotchas Input concurrency is a powerful feature, but there are a few caveats that can be useful to be aware of before adopting it. ### Input cancellations Synchronous and asynchronous Functions handle input cancellations differently. Modal will raise a `modal.exception.InputCancellation` exception in synchronous Functions and an `asyncio.CancelledError` in asynchronous Functions. When using input concurrency with a synchronous Function, a single input cancellation will terminate the entire container. If your workflow depends on graceful input cancellations, we recommend using an asynchronous implementation. ### Concurrent logging The separate threads or tasks that are executing the concurrent inputs will write any logs to the same stream. This makes it difficult to associate logs with a specific input, and filtering for a specific function call in Modal's web dashboard will show logs for all inputs running at the same time. To work around this, we recommend including a unique identifier in the messages you log (either your own identifier or the `modal.current_input_id()`) so that you can use the search functionality to surface logs for a specific input: ```python @app.function() @modal.concurrent(max_inputs=10) async def better_concurrent_logging(x: int): logger.info(f"{modal.current_input_id()}: Starting work with {x}") ``` #### Batch processing # Batch Processing Modal is optimized for large-scale batch processing, allowing functions to scale to thousands of parallel containers with zero additional configuration. Function calls can be submitted asynchronously for background execution, eliminating the need to wait for jobs to finish or tune resource allocation. This guide covers Modal's batch processing capabilities, from basic invocation to integration with existing pipelines. ## Background Execution with `.spawn_map` The fastest way to submit multiple jobs for asynchronous processing is by invoking a Function with `.spawn_map`. When combined with the [`--detach`](https://modal.com/docs/cli/latest/run) flag, your App continues running until all jobs are completed. Here's an example of submitting 100,000 videos for parallel embedding. You can disconnect after submission, and the processing will continue to completion in the background: ```python # Kick off asynchronous jobs with `modal run --detach batch_processing.py` import modal app = modal.App("batch-processing-example") volume = modal.Volume.from_name("video-embeddings", create_if_missing=True) @app.function(volumes={"/data": volume}) def embed_video(video_id: int): # Business logic: # - Load the video from the volume # - Embed the video # - Save the embedding to the volume ... @app.local_entrypoint() def main(): embed_video.spawn_map(range(100_000)) ``` This pattern works best for jobs that store results externally—for example, in a [Modal Volume](https://modal.com/docs/guide/volumes), [Cloud Bucket Mount](https://modal.com/docs/guide/cloud-bucket-mounts), or your own database\*. _\* For database connections, consider using [Modal Proxy](https://modal.com/docs/guide/proxy-ips) to maintain a static IP across thousands of containers._ ## Parallel Processing with `.map` Using `.map` allows you to offload expensive computations to powerful machines while gathering results. This is particularly useful for pipeline steps with bursty resource demands. Modal handles all infrastructure provisioning and de-provisioning automatically. Here's how to implement parallel video similarity queries as a single Modal Function call: ```python # Run jobs and collect results with `modal run gather.py` import modal app = modal.App("gather-results-example") @app.function(gpu="L40S") def compute_video_similarity(query: str, video_id: int) -> tuple[int, int]: # Embed video with GPU acceleration & compute similarity with query return video_id, score @app.local_entrypoint() def main(): import itertools queries = itertools.repeat("Modal for batch processing") video_ids = range(100_000) for video_id, score in compute_video_similarity.map(queries, video_ids): # Process results (e.g., extract top 5 most similar videos) pass ``` This example runs `compute_video_similarity` on an autoscaling pool of L40S GPUs, returning scores to a local process for further processing. ## Integration with Existing Systems The recommended way to use Modal Functions within your existing data pipeline is through [deployed Function invocation](https://modal.com/docs/guide/trigger-deployed-functions). After deployment, you can call Modal Functions from external systems: ```python def external_function(inputs): compute_similarity = modal.Function.from_name( "gather-results-example", "compute_video_similarity" ) for result in compute_similarity.map(inputs): # Process results pass ``` You can invoke Modal Functions from any Python context, gaining access to built-in observability, resource management, and GPU acceleration. #### Job queues # Job processing Modal can be used as a scalable job queue to handle asynchronous tasks submitted from a web app or any other Python application. This allows you to offload up to 1 million long-running or resource-intensive tasks to Modal, while your main application remains responsive. ## Creating jobs with .spawn() The basic pattern for using Modal as a job queue involves three key steps: 1. Defining and deploying the job processing function using `modal deploy`. 2. Submitting a job using [`modal.Function.spawn()`](https://modal.com/docs/sdk/py/latest/Function#spawn) 3. Polling for the job's result using [`modal.FunctionCall.get()`](https://modal.com/docs/sdk/py/latest/FunctionCall#get) Here's a simple example that you can run with `modal run my_job_queue.py`: ```python # my_job_queue.py import modal app = modal.App("my-job-queue") @app.function() def process_job(data): # Perform the job processing here return {"result": data} def submit_job(data): # Since the `process_job` function is deployed, need to first look it up process_job = modal.Function.from_name("my-job-queue", "process_job") call = process_job.spawn(data) return call.object_id def get_job_result(call_id): function_call = modal.FunctionCall.from_id(call_id) try: result = function_call.get(timeout=5) except modal.exception.OutputExpiredError: result = {"result": "expired"} except TimeoutError: result = {"result": "pending"} return result @app.local_entrypoint() def main(): data = "my-data" # Submit the job to Modal call_id = submit_job(data) print(get_job_result(call_id)) ``` In this example: - `process_job` is the Modal Function that performs the actual job processing. To deploy the `process_job` Function on Modal, run `modal deploy my_job_queue.py`. - `submit_job` submits a new job by first looking up the deployed `process_job` Function, then calling `.spawn()` with the job data. It returns the unique ID of the spawned Function call. - `get_job_result` attempts to retrieve the result of a previously submitted job using [`FunctionCall.from_id()`](https://modal.com/docs/sdk/py/latest/FunctionCall#from_id) and [`FunctionCall.get()`](https://modal.com/docs/sdk/py/latest/FunctionCall#get). [`FunctionCall.get()`](https://modal.com/docs/sdk/py/latest/FunctionCall#get) waits indefinitely by default. It takes an optional timeout argument that specifies the maximum number of seconds to wait, which can be set to 0 to poll for an output immediately. Here, if the job hasn't completed yet, we return a pending response. - The results of a `.spawn()` are accessible via `FunctionCall.get()` for up to 7 days after completion. After this period, we return an expired response. [Document OCR Web App](https://modal.com/docs/examples/doc_ocr_webapp) is an example that uses this pattern. ## Integration with web frameworks You can easily integrate the job queue pattern with web frameworks like FastAPI. Here's an example, assuming that you have already deployed `process_job` on Modal with `modal deploy` as above. This example won't work if you haven't deployed your app yet. ```python # my_job_queue_endpoint.py import modal image = modal.Image.debian_slim().pip_install("fastapi[standard]") app = modal.App("fastapi-modal", image=image) @app.function() @modal.asgi_app() @modal.concurrent(max_inputs=20) def fastapi_app(): from fastapi import FastAPI web_app = FastAPI() @web_app.post("/submit") async def submit_job_endpoint(data): process_job = modal.Function.from_name("my-job-queue", "process_job") call = await process_job.spawn.aio(data) return {"call_id": call.object_id} @web_app.get("/result/{call_id}") async def get_job_result_endpoint(call_id: str): function_call = modal.FunctionCall.from_id(call_id) try: result = await function_call.get.aio(timeout=0) except modal.exception.OutputExpiredError: return fastapi.responses.JSONResponse(content="", status_code=404) except TimeoutError: return fastapi.responses.JSONResponse(content="", status_code=202) return result return web_app ``` In this example: - The `/submit` endpoint accepts job data, submits a new job using `await process_job.spawn.aio()`, and returns the job's ID to the client. - The `/result/{call_id}` endpoint allows the client to poll for the job's result using the job ID. If the job hasn't completed yet, it returns a 202 status code to indicate that the job is still being processed. If the job has expired, it returns a 404 status code to indicate that the job is not found. You can try this app by serving it with `modal serve`: ```shell modal serve my_job_queue_endpoint.py ``` Then interact with its endpoints with `curl`: ```shell # Make a POST request to your app endpoint with. $ curl -X POST $YOUR_APP_ENDPOINT/submit?data=data {"call_id":"fc-XXX"} # Use the call_id value from above. $ curl -X GET $YOUR_APP_ENDPOINT/result/fc-XXX ``` ## Scaling and reliability Modal automatically scales the job queue based on the workload, spinning up new instances as needed to process jobs concurrently. It also provides built-in reliability features like automatic retries and timeout handling. You can customize the behavior of the job queue by configuring the `@app.function()` decorator with options like [`retries`](https://modal.com/docs/guide/retries#function-retries), [`timeout`](https://modal.com/docs/guide/timeouts#timeouts), and [`max_containers`](https://modal.com/docs/guide/scale#configuring-autoscaling-behavior). #### Dynamic batching # Dynamic batching Modal's `@batched` feature allows you to accumulate requests and process them in dynamically-sized batches, rather than one-by-one. Batching increases throughput at a potential cost to latency. Batched requests can share resources and reuse work, reducing the time and cost per request. Batching is particularly useful for GPU-accelerated machine learning workloads, as GPUs are designed to maximize throughput and are frequently bottlenecked on shareable resources, like weights stored in memory. Static batching can lead to unbounded latency, as the function waits for a fixed number of requests to arrive. Modal's dynamic batching waits for the lesser of a fixed time _or_ a fixed number of requests before executing, maximizing the throughput benefit of batching while minimizing the latency penalty. ## Enable dynamic batching with `@batched` To enable dynamic batching, apply the [`@modal.batched` decorator](https://modal.com/docs/sdk/py/latest/batched) to the target Python function. Then, wrap it in `@app.function()` and run it on Modal, and the inputs will be accumulated and processed in batches. Here's what that looks like: ```python import modal app = modal.App() @app.function() @modal.batched(max_batch_size=2, wait_ms=1000) async def batch_add(xs: list[int], ys: list[int]) -> list[int]: return [x + y for x, y in zip(xs, ys)] ``` When you invoke a function decorated with `@batched`, you invoke it asynchronously on individual inputs. Outputs are returned where they were invoked. For instance, the code below invokes the decorated `batch_add` function above three times, but `batch_add` only executes twice: ```python continuation @app.local_entrypoint() async def main(): inputs = [(1, 300), (2, 200), (3, 100)] async for result in batch_add.starmap.aio(inputs): print(f"Sum: {result}") # Sum: 301 # Sum: 202 # Sum: 103 ``` The first time it is executed with `xs` batched to `[1, 2]` and `ys` batched to `[300, 200]`. After about a one second delay, it is executed with `xs` batched to `[3]` and `ys` batched to `[100]`. The result is an iterator that yields `301`, `202`, and `103`. ## Use `@batched` with functions that take and return lists For a Python function to be compatible with `@modal.batched`, it must adhere to the following rules: - ** The inputs to the function must be lists. ** In the example above, we pass `xs` and `ys`, which are both lists of `int`s. - ** The function must return a list**. In the example above, the function returns a list of sums. - ** The lengths of all the input lists and the output list must be the same. ** In the example above, if `L == len(xs) == len(ys)`, then `L == len(batch_add(xs, ys))`. ## Modal `Cls` methods are compatible with dynamic batching Methods on Modal [`Cls`](https://modal.com/docs/guide/lifecycle-functions)es also support dynamic batching. ```python import modal app = modal.App() @app.cls() class BatchedClass(): @modal.batched(max_batch_size=2, wait_ms=1000) async def batch_add(self, xs: list[int], ys: list[int]) -> list[int]: return [x + y for x, y in zip(xs, ys)] ``` One additional rule applies to classes with Batched Methods: - If a class has a Batched Method, it **cannot have other Batched Methods or [Methods](https://modal.com/docs/sdk/py/latest/method)**. ## Configure the wait time and batch size of dynamic batches The `@batched` decorator takes in two required configuration parameters: - `max_batch_size` limits the number of inputs combined into a single batch. - `wait_ms` limits the amount of time the Function waits for more inputs after the first input is received. The first invocation of the Batched Function initiates a new batch, and subsequent calls add requests to this ongoing batch. If `max_batch_size` is reached, the batch immediately executes. If the `max_batch_size` is not met but `wait_ms` has passed since the first request was added to the batch, the unfilled batch is executed. ### Selecting a batch configuration To optimize the batching configurations for your application, consider the following heuristics: - Set `max_batch_size` to the largest value your function can handle, so you can amortize and parallelize as much work as possible. - Set `wait_ms` to the difference between your targeted latency and the execution time. Most applications have a targeted latency, and this allows the latency of any request to stay within that limit. ## Serve Web Functions with dynamic batching Here's a simple example of serving a Function that batches requests dynamically with a [`@modal.fastapi_endpoint`](https://modal.com/docs/guide/webhooks). Run [`modal serve`](https://modal.com/docs/cli/latest/serve), submit requests to the endpoint, and the Function will batch your requests on the fly. ```python import modal app = modal.App(image=modal.Image.debian_slim().pip_install("fastapi")) @app.function() @modal.batched(max_batch_size=2, wait_ms=1000) async def batch_add(xs: list[int], ys: list[int]) -> list[int]: return [x + y for x, y in zip(xs, ys)] @app.function() @modal.fastapi_endpoint(method="POST", docs=True) async def add(body: dict[str, int]) -> dict[str, int]: result = await batch_add.remote.aio(body["x"], body["y"]) return {"result": result} ``` Now, you can submit requests to the Web Function and process them in batches. For instance, the three requests in the following example, which might be requests from concurrent clients in a real deployment, will be batched into two executions: ```python notest import asyncio import aiohttp async def send_post_request(session, url, data): async with session.post(url, json=data) as response: return await response.json() async def main(): # Enter the Web Function URL here url = "https://workspace--app-name-endpoint-name.modal.run" async with aiohttp.ClientSession() as session: # Submit three requests asynchronously tasks = [ send_post_request(session, url, {"x": 1, "y": 300}), send_post_request(session, url, {"x": 2, "y": 200}), send_post_request(session, url, {"x": 3, "y": 100}), ] results = await asyncio.gather(*tasks) for result in results: print(f"Sum: {result['result']}") asyncio.run(main()) ``` #### Multi-node clusters (Beta) # Multi-node clusters Modal supports running a training job across several coordinated containers. Each container can saturate the available GPU devices on its host (aka node) and communicate with peer containers which do the same. By scaling a training job from a single GPU to 16 GPUs you can achieve nearly 16x improvements in training time. ### Cluster compute capability Modal clusters provide: - A 50 Gbps [IPv6 private network](https://modal.com/docs/guide/private-networking) for orchestration, dataset downloading, etc. - A 3,200 Gbps RDMA scale-out network ([RoCE](https://en.wikipedia.org/wiki/RDMA_over_Converged_Ethernet)). - Up to 64 devices. - At least 1 TB of RAM and 4 TB of local NVMe SSD per node. - Deep burn-in testing. - Interoperability with all Modal platform functionality ([Volumes](https://modal.com/docs/guide/volumes), [Dicts](https://modal.com/docs/guide/dicts), [Tunnels](https://modal.com/docs/guide/tunnels), etc.). The guide will walk you through how the Modal client library enables multi-node training and integrates with `torchrun`. ### `@clustered` Unlike standard Modal Function containers, containers in a multi-node training job must be able to: 1. Perform fast, direct network communication between each other. 2. Be scheduled together, all or nothing, at the same time. The `@clustered` decorator enables this behavior. ```python import modal.experimental @app.function( gpu="H100:8", timeout=60 * 60 * 24, retries=modal.Retries(initial_delay=0.0, max_retries=10), ) @modal.experimental.clustered(size=4) def train_model(): cluster_info = modal.experimental.get_cluster_info() container_rank = cluster_info.rank world_size = len(cluster_info.container_ips) main_addr = cluster_info.container_ips[0] is_main = "(main)" if container_rank == 0 else "" print(f"{container_rank=} {is_main} {world_size=} {main_addr=}") ... ``` Applying this decorator under `@app.function` modifies the Function so that remote calls to it are serviced by a multi-node container group. The above configuration creates a group of four containers each having 8 H100 GPU devices, for a total of 32 devices. Starting May 31st, 2026, clustered functions must use the full number of GPU devices per node (e.g. `H100:4` is invalid but `H100:8` is valid). Clustered functions require GPUs, and CPU-only functions are not supported. If you have a special case that does not fall under above and would like to use clustered functions, please contact [support@modal.com](mailto:support@modal.com). ## Scheduling A `modal.experimental.clustered` Function runs on multiple nodes in our cloud, but executes like a normal function call. For example, all nodes are scheduled together ([gang scheduling](https://en.wikipedia.org/wiki/Gang_scheduling)) so that your code runs on all of the requested hardware or not at all. Traditionally this kind of cluster and scheduling management would be handled by SLURM, Kubernetes, or manually. But with Modal it's all provided serverlessly with just a Python decorator! ### Rank & input broadcast ![diagram](https://modal-cdn.com/cdnbot/multinodepmgnla70_4b57a155.webp) You may notice above that a single `.remote` Function call created three input executions but returned only one output. This is how input-output is structured for multi-node training jobs on Modal. The Function call’s arguments are replicated to each container, but only the rank zero container’s is returned to the caller. A container’s rank is a key concept in multi-node jobs. Rank zero is the 'leader' rank and typically coordinates the job. Rank zero is also known as the "main" container. Rank zero's output will always be the output of a multi-node training run. ## Networking Function containers cannot normally make direct network connections to other Function containers, but this is a requirement for multi-node training communication. So, along with gang scheduling, the `@clustered` decorator enables Modal’s workspace-private inter-container networking called [i6pn](https://www.notion.so/Multi-node-docs-1281e7f16949806f966adedfe8b2cb74?pvs=21). The [cluster networking guide](https://modal.com/docs/guide/private-networking) goes into more detail on i6pn, but the upshot is that each container in the cluster is made aware of the network address of all the other containers in the cluster, enabling them to communicate with each other quickly via [TCP](https://pytorch.org/docs/stable/elastic/rendezvous.html). ### RDMA (Infiniband) Clusters are equipped with Infiniband providing up to 3,200 Gbps scale-out bandwidth for inter-node communication. RDMA scale-out networking is enabled with the `rdma` parameter of `modal.experimental.clustered`. ```python notest @modal.experimental.clustered(size=2, rdma=True) def train(): ... ``` To run a simple Infiniband RDMA performance test see the [this sample code](https://github.com/modal-labs/multinode-training-guide/tree/main/benchmark). ## Cluster Info `modal.experimental.get_cluster_info()` exposes the following information about the cluster: - `rank: int` is the current container's order within the cluster, starting from `0`, the leader. - `cluster_id: str` is the unique identifier for the cluster. - `container_ips: list[str]` contains the IPv6 addresses of each container in the cluster, sorted by rank. - `container_ipv4_ips: list[str]` contains the IPv4 addresses of each container in the cluster, sorted by rank. ## Fault Tolerance For a clustered Function, failures in inputs and containers are handled differently. If an input fails on any container, this failure **is not propagated** to other containers in the cluster. Containers are responsible for detecting and responding to input failures on other containers. Only rank 0's output matters: if an input fails on the leader container (rank 0), the input is marked as failed, even if the input succeeds on another container. Similarly, if an input succeeds on the leader container but fails on another container, the input will still be marked as successful. If a container in the cluster is preempted, or if the leader container (rank 0) fails, Modal will terminate all remaining containers in the cluster, and retry the input. ### Input Synchronization _**Important:**_ synchronization is not relevant for single training runs, and applies mostly to inference use-cases. Modal does not synchronize input execution across containers. Containers are responsible for ensuring that they do not process inputs faster than other containers in their cluster. In particular, it is important that the leader container (rank 0) only starts processing the next input after all other containers have finished processing the current input. ## Examples To get hands-on with multi-node training you can jump into the [`Modal Training Gym`](https://gym.modal.dev), [`multinode-training-guide` repository](https://github.com/modal-labs/multinode-training-guide), or [`modal-examples` repository](https://github.com/modal-labs/modal-examples/tree/main/14_clusters) and `modal run` something! - [Simple ‘hello world’ 4 x 1 H100 torch cluster example](https://github.com/modal-labs/modal-examples/blob/main/14_clusters/simple_torch_cluster.py) - [Infiniband RDMA performance test](https://github.com/modal-labs/multinode-training-guide/tree/main/benchmark) - [Use 2 x 8 H100s to train a ResNet50 model on the ImageNet dataset](https://github.com/modal-labs/multinode-training-guide/tree/main/resnet50) - [Speedrun GPT-2 training with modded-nanogpt](https://github.com/modal-labs/multinode-training-guide/tree/main/nanoGPT) ### Torchrun Example ```python import modal import modal.experimental image = ( modal.Image.debian_slim(python_version="3.12") .pip_install("torch~=2.5.1", "numpy~=2.2.1") .add_local_dir( "training", remote_path="/root/training" ) ) app = modal.App("example-simple-torch-cluster", image=image) n_nodes = 4 @app.function(gpu=f"H100:8", timeout=60 * 60 * 24) @modal.experimental.clustered(size=n_nodes, rdma=True) def launch_torchrun(): # import the 'torchrun' interface directly. from torch.distributed.run import parse_args, run cluster_info = modal.experimental.get_cluster_info() run( parse_args( [ f"--nnodes={n_nodes}", f"--node-rank={cluster_info.rank}", f"--master-addr={cluster_info.container_ips[0]}", "--nproc-per-node=8", "--master-port=1234", "training/train.py", ] ) ) ``` ### Deployment #### Apps, Functions, and entrypoints # Apps, Functions, and entrypoints An [`App`](https://modal.com/docs/sdk/py/latest/App) represents an application running on Modal. It groups one or more Functions for atomic deployment and acts as a shared namespace. All Functions and Clses are associated with an App. A [`Function`](https://modal.com/docs/sdk/py/latest/Function) acts as an independent unit once it is deployed, and [scales up and down](https://modal.com/docs/guide/scale) independently from other Functions. If there are no live inputs to the Function then by default, no containers will run and your account will not be charged for compute resources, even if the App it belongs to is deployed. An App can be ephemeral or deployed. You can view a list of all currently running Apps on the [`apps`](https://modal.com/apps) page. The code for a Modal App defining two separate Functions might look something like this: ```python import modal app = modal.App(name="my-modal-app") @app.function() def f(): print("Hello world!") @app.function() def g(): print("Goodbye world!") ``` ## Ephemeral Apps An ephemeral App is created when you use the [`modal run`](https://modal.com/docs/cli/latest/run) CLI command, or the [`app.run`](https://modal.com/docs/sdk/py/latest/App#run) method. This creates a temporary App that only exists for the duration of your script. Ephemeral Apps are stopped automatically when the calling program exits, or when the server detects that the client is no longer connected. You can use [`--detach`](https://modal.com/docs/cli/latest/run) in order to keep an ephemeral App running even after the client exits. By using `app.run` you can run your Modal Apps from within your Python scripts: ```python def main(): ... with app.run(): some_modal_function.remote() ``` By default, running your App in this way won't propagate Modal logs and progress bar messages. To enable output, use the [`modal.enable_output`](https://modal.com/docs/sdk/py/latest/enable_output) context manager: ```python def main(): ... with modal.enable_output(): with app.run(): some_modal_function.remote() ``` ## Deployed Apps A deployed App is created using the [`modal deploy`](https://modal.com/docs/cli/latest/deploy) CLI command. The App is persisted indefinitely until you stop it via the [web UI](https://modal.com/apps) or the [`modal app stop`](https://modal.com/docs/cli/latest/app#modal-app-stop) command. Functions in a deployed App that have an attached [schedule](https://modal.com/docs/guide/cron) will be run on a schedule. Otherwise, you can invoke them manually using [Web Functions or Python](https://modal.com/docs/guide/trigger-deployed-functions). Deployed Apps are named via the [`App`](https://modal.com/docs/sdk/py/latest/App) constructor. Re-deploying an existing `App` (based on the name) will update it in place. ## Entrypoints for ephemeral Apps The code that runs first when you `modal run` an App is called the "entrypoint". You can register a local entrypoint using the [`@app.local_entrypoint()`](https://modal.com/docs/sdk/py/latest/App#local_entrypoint) decorator. You can also use a regular Modal Function as an entrypoint, in which case only the code in global scope is executed locally. ### Argument parsing If your entrypoint function takes arguments with primitive types, `modal run` automatically parses them as CLI options. For example, the following function can be called with `modal run script.py --foo 1 --bar "hello"`: ```python # script.py @app.local_entrypoint() def main(foo: int, bar: str): some_modal_function.remote(foo, bar) ``` If you wish to use your own argument parsing library, such as `argparse`, you can instead accept a variable-length argument list for your entrypoint or your function. In this case, Modal skips CLI parsing and forwards CLI arguments as a tuple of strings. For example, the following function can be invoked with `modal run my_file.py --foo=42 --bar="baz"`: ```python import argparse @app.function() def train(*arglist): parser = argparse.ArgumentParser() parser.add_argument("--foo", type=int) parser.add_argument("--bar", type=str) args = parser.parse_args(args = arglist) ``` ### Manually specifying an entrypoint If there is only one `local_entrypoint` registered, [`modal run script.py`](https://modal.com/docs/cli/latest/run) will automatically use it. If you have no entrypoint specified, and just one decorated Modal Function, that will be used as a remote entrypoint instead. Otherwise, you can direct `modal run` to use a specific entrypoint. For example, if you have a function decorated with [`@app.function()`](https://modal.com/docs/sdk/py/latest/App#function) in your file: ```python # script.py @app.function() def f(): print("Hello world!") @app.function() def g(): print("Goodbye world!") @app.local_entrypoint() def main(): f.remote() ``` Running [`modal run script.py`](https://modal.com/docs/cli/latest/run) will execute the `main` function locally, which would call the `f` function remotely. However you can instead run `modal run script.py::app.f` or `modal run script.py::app.g` to execute `f` or `g` directly. ## Apps were once Stubs The `modal.App` class in the client was previously called `modal.Stub`. The old name was kept as an alias for some time, but from Modal 1.0.0 onwards, using `modal.Stub` will result in an error. #### Managing deployments # Managing deployments Once you've finished using `modal run` or `modal serve` to iterate on your Modal code, it's time to deploy. A Modal deployment creates and then persists an App and its objects, providing the following benefits: - Repeated executions of the App's Functions will be grouped under the Deployment, aiding observability and usage tracking. Programmatically triggering lots of ephemeral App runs can clutter your web and CLI interfaces. - Function calls are much faster because deployed Functions are persistent and reused, not created on-demand by calls. Learn how to trigger deployed Functions in [Invoking deployed Functions](https://modal.com/docs/guide/trigger-deployed-functions). - [Scheduled Functions](https://modal.com/docs/guide/cron) will continue scheduling separate from any local iteration you do, and will notify you on failure. - [Web Functions](https://modal.com/docs/guide/webhooks) keep running when you close your laptop, and their URL address matches the deployment name. ## Creating deployments Deployments are created using the [`modal deploy`](https://modal.com/docs/cli/latest/deploy) command. ``` % modal deploy -m whisper_pod_transcriber.main ✓ Initialized. View app page at https://modal.com/apps/ap-PYc2Tb7JrkskFUI8U5w0KG. ✓ Created objects. ├── 🔨 Created populate_podcast_metadata. ├── 🔨 Mounted /home/ubuntu/whisper_pod_transcriber at /root/whisper_pod_transcriber ├── 🔨 Created fastapi_app => https://modal-labs-whisper-pod-transcriber-fastapi-app.modal.run ├── 🔨 Mounted /home/ubuntu/whisper_pod_transcriber/whisper_frontend/dist at /assets ├── 🔨 Created search_podcast. ├── 🔨 Created refresh_index. ├── 🔨 Created transcribe_segment. ├── 🔨 Created transcribe_episode.. └── 🔨 Created fetch_episodes. ✓ App deployed! 🎉 View Deployment: https://modal.com/apps/modal-labs/whisper-pod-transcriber ``` Running this command on an existing deployment will redeploy the App, incrementing its version. For detail on how live deployed Apps transition between versions, see the [Updating deployments](#updating-deployments) section. Deployments can also be created programmatically using the [`app.deploy()`](https://modal.com/docs/sdk/py/latest/App#deploy) method in Modal's Python SDK. ## Viewing deployments Deployments can be viewed in the [web UI](https://modal.com/apps) on an App's "Deployment History" page, or from the command line using the [`modal app list`](https://modal.com/docs/cli/latest/app#modal-app-list) command. ### Deployment events on charts You can overlay deployment history information on your Function's metric charts by enabling the **Show Deployments** toggle. Each marker represents one or more deployments that occurred within a time bucket. Hovering over a marker shows the version number and timestamp of each deployment, plus a link to the full "Deployment History" page. ![Deployment history overlay on a metric chart](https://modal-cdn.com/cdnbot/deployment-historyt991cvw__b284b7fa.webp) ## Updating deployments A deployment can create a new App or redeploy an existing deployed App with a new version. It's useful to understand how Modal handles the transition between versions when an App is redeployed. In general, Modal aims to support zero-downtime deployments by gradually transitioning traffic to the new version, but it is also possible to opt into a sharp cutover between versions. If the deployment involves building new versions of the Images used by the App, the build process will need to complete successfully before any new containers are started. The existing version of the App will continue to handle inputs during this time. Errors during the build will abort the deployment with no change to the status of the App. ### Deployment strategies After the build completes, Modal will start to bring up new containers running the latest version of the App. The exact mechanics depend on the choice of deployment strategy, configured with `--strategy` in the [`modal deploy`](https://modal.com/docs/cli/latest/deploy) CLI or `strategy=` in the [`app.deploy()`](https://modal.com/docs/sdk/py/latest/App#deploy) method. With the default `rolling` strategy, existing containers will continue handling inputs (using the previous version of the App) until new containers have completed their cold start. Traffic will shift over to these new containers as they come online, but old containers will not shut down until they finish processing any inputs they were assigned. With the opt-in `recreate` strategy, the transition between versions will be more abrupt. Existing containers will be terminated as soon as the new version is active, and inputs will queue until new containers come online (including inputs that were running on old containers, which will be retried on new ones). The `rolling` strategy avoids downtime and is recommended for any production Apps. The `recreate` strategy is primarily useful during development, because you can be certain that new containers will be used for any inputs sent after the deployment command returns. ## No-op deployments and rollovers The App is the unit of deployment. If nothing in the App configuration has changed, the deployment command will be a no-op, and the App version will not increment. However, changes to any Function will cause all Functions to update. It's possible to cycle the containers serving an App without any changes to the code or configuration by using the [`modal app rollover`](https://modal.com/docs/cli/latest/app#modal-app-rollover) command. This may be necessary if the App depends on a Secret or some other external resource that is loaded at container startup and has become invalidated. A rollover event will appear in the deployment history as a new version. As with a normal deployment, a rollover can be performed with either a `rolling` or `recreate` strategy. ## Deployment rollbacks Deployment rollbacks are available on the Team and Enterprise plans. Visit workspace settings to upgrade. To quickly reset an App back to a previous version (e.g., if you discover that a new version has a serious defect), you can perform a deployment _rollback_. Rollbacks can be triggered from the Deployment History tab in the App dashboard or using the [`modal app rollback`](https://modal.com/docs/cli/latest/app#modal-app-rollback) CLI. Rollback deployments look like new deployments: they increment the version number and are attributed to the user who triggered the rollback. But the App's Functions and metadata will be reset to their previous state independently of your current App codebase. ## Stopping deployments Deployed Apps can be stopped in the web UI by clicking the red "Stop app" button on the App's "Overview" page, or alternatively from the command line using the [`modal app stop`](https://modal.com/docs/cli/latest/app#modal-app-stop) command. Stopping an App is a destructive action. Apps cannot be restarted from this state; a new App will need to be deployed from the same source files. Objects associated with stopped deployments will eventually be garbage collected. #### Continuous deployment # Continuous deployment It's a common pattern to auto-deploy your Modal App as part of a CI/CD pipeline. To get you started, below is a guide to doing continuous deployment of a Modal App in GitHub. ## GitHub Actions Here's a sample GitHub Actions workflow that deploys your App on every push to the `main` branch. This requires you to create a [Modal token](https://modal.com/settings/tokens) and add it as a [secret for your Github Actions workflow](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets). After setting up secrets, create a new workflow file in your repository at `.github/workflows/ci-cd.yml` with the following contents: ```yaml name: CI/CD on: push: branches: - main jobs: deploy: name: Deploy runs-on: ubuntu-latest env: MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} steps: - name: Checkout Repository uses: actions/checkout@v6 - name: Install Python uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install Modal run: | python -m pip install --upgrade pip pip install modal - name: Deploy job run: | modal deploy -m my_package.my_file ``` Be sure to replace `my_package.my_file` with your actual entrypoint. If you use multiple Modal [Environments](https://modal.com/docs/guide/environments), you can additionally specify the target environment in the YAML using `MODAL_ENVIRONMENT=xyz`. #### Scheduled Functions # Scheduling remote cron jobs A common requirement is to perform some task at a given time every day or week automatically. Modal facilitates this through function schedules. ## Basic scheduling Let's say we have a Python module `heavy.py` with a function, `perform_heavy_computation()`. ```python # heavy.py def perform_heavy_computation(): ... if __name__ == "__main__": perform_heavy_computation() ``` To schedule this function to run once per day, we create a Modal App and attach our function to it with the `@app.function` decorator and a schedule parameter: ```python # heavy.py import modal app = modal.App() @app.function(schedule=modal.Period(days=1)) def perform_heavy_computation(): ... ``` To activate the schedule, deploy your App, either through the CLI: ```shell modal deploy --name daily_heavy heavy.py ``` Or programmatically: ```python if __name__ == "__main__": app.deploy() ``` Now the function will run every day, at the time of the initial deployment, without any further interaction on your part. When you make changes to your Function, just rerun the deploy command to overwrite the old deployment. Note that when you redeploy your Function, `modal.Period` resets, and the schedule will run X hours after this most recent deployment. If you want to run your Function at a regular schedule not disturbed by deploys, `modal.Cron` (see below) is a better option. ## Monitoring your scheduled runs To see past execution logs for the scheduled Function, go to the [Apps](https://modal.com/apps) section on the Modal web site. Schedules currently cannot be paused. Instead the schedule should be removed and the App redeployed. Schedules can be started manually on the App's dashboard page, using the "run now" button. ## Schedule types There are two kinds of base schedule values - [`modal.Period`](https://modal.com/docs/sdk/py/latest/Period) and [`modal.Cron`](https://modal.com/docs/sdk/py/latest/Cron). [`modal.Period`](https://modal.com/docs/sdk/py/latest/Period) lets you specify an interval between function calls, e.g. `Period(days=1)` or `Period(hours=5)`: ```python # runs once every 5 hours @app.function(schedule=modal.Period(hours=5)) def perform_heavy_computation(): ... ``` [`modal.Cron`](https://modal.com/docs/sdk/py/latest/Cron) gives you finer control using [cron](https://en.wikipedia.org/wiki/Cron) syntax: ```python # runs at 8 am (UTC) every Monday @app.function(schedule=modal.Cron("0 8 * * 1")) def perform_heavy_computation(): ... # runs daily at 6 am (New York time) @app.function(schedule=modal.Cron("0 6 * * *", timezone="America/New_York")) def send_morning_report(): ... ``` For more details, see the API reference for [Period](https://modal.com/docs/sdk/py/latest/Period), [Cron](https://modal.com/docs/sdk/py/latest/Cron) and [Function](https://modal.com/docs/sdk/py/latest/Function) ### HTTP Applications #### Servers # Servers Modal Servers are a serverless compute primitive optimized for low-latency HTTP communication between external clients and a process running in a container on Modal. ```python @app.server(unauthenticated=True) class Server: @modal.enter() def startup(self): import subprocess subprocess.Popen("python -m http.server -d / 8000", shell=True) ``` The Modal Server primitive provides the underlying infrastructure for [Endpoints](https://modal.com/docs/guide/endpoints). They can also be deployed directly with fully customized application logic. Modal Servers share many features with [Modal Functions](https://modal.com/docs/guide/functions). They are members of a Modal App and are [deployed](https://modal.com/docs/guide/managing-deployments) through the normal [`modal deploy`](https://modal.com/docs/cli/latest/deploy) workflow. Server resource configuration has the same [baseline request + burst semantics](https://modal.com/docs/guide/resources) as Functions, and of course they can use [GPUs](https://modal.com/docs/guide/gpu) too. Server containers can [run anywhere](https://modal.com/docs/guide/region-selection) in our global fleet, using [fully customized](https://modal.com/docs/guide/images) Images, and they benefit from the same snappy cold boot performance (including [memory snapshots](https://modal.com/docs/guide/memory-snapshots)). They can mount [Secrets](https://modal.com/docs/guide/secrets) and [Volumes](https://modal.com/docs/guide/volumes) and have a stable [outbound IP address](https://modal.com/docs/guide/proxy-ips). This is a high-level guide to Modal Servers. For reference documentation, see the [`@app.server()`](https://modal.com/docs/sdk/py/latest/App#server) decorator and [`modal.Server`](https://modal.com/docs/sdk/py/latest/Server) object reference pages. This guide emphasizes the _differences_ between Servers and Functions. Servers were designed from the ground up to provide ultra-low latency for processes that listen on a port and speak HTTP natively. This motivates some important differences around autoscaling, load-leveling, authentication, and container lifecycle. It also means that Servers lack some operational features of Modal Functions that depend on the stateful Function input system. ## Defining a Server A Modal Server is defined with a class that uses Modal’s [lifecycle decorators](https://modal.com/docs/guide/lifecycle-functions) on methods that specify container startup (and, optionally, shutdown) logic. The class itself is registered with an App using the `@app.server()` decorator, which takes the main set of Server configuration parameters. The startup logic must initialize a server process that binds to 0.0.0.0 and listens on a port (`8000` by default). Unlike a Modal Cls, Server definitions cannot use `@modal.method()` or Web Function decorators like `@modal.fastapi_endpoint`. The request handling is performed by the process listening on the port, not a method on the class. A Modal Server is most directly analogous to a Modal Function using the `@modal.web_server()` decorator, and most web server Functions can be directly migrated to a Server, so long as the migration accounts for the different behaviors and configuration models discussed in this guide. Every Server is assigned a URL as its public interface. A Server’s URL can be retrieved programmatically using `modal.Server.get_url()`. ## Concurrency and autoscaling Modal Function containers process one input at a time unless they explicitly opt into [input concurrency](https://modal.com/docs/guide/concurrent-inputs), and Modal will [autoscale](https://modal.com/docs/guide/scale) additional Function containers to meet demand. Servers invert this: Server processes are expected to handle concurrent requests, and the Server configuration must explicitly opt into container autoscaling when desired. To enable autoscaling, provide a `target_concurrency=` value in the `@app.server()` decorator. Modal will use this target to manage the Server’s container pool, scaling towards a desired number of containers based on each container’s concurrent request load. Note that it provides only a soft limit. If the Server process cannot handle a given level of request concurrency, the process must perform its own load-leveling or load-shedding. Servers can use the standard `min_containers=`, `max_containers=`, and `buffer_containers=` parameters to bound the autoscaler or to [keep additional containers warm](https://modal.com/docs/guide/cold-start). They can also use `scaleup_window=` and `scaledown_window=` to tune the autoscaler’s responsiveness to fluctuations in request rates. The Server autoscaling configuration can be dynamically tuned using `modal.Server.update_autoscaler()`. As with Functions, any dynamic configuration will be reset by a subsequent deployment. If the Server configuration leaves `target_concurrency=` unset but provisions multiple containers via `min_containers=`, requests will be distributed across the pool. If a singleton container is desired, it is preferable to leave `target_concurrency=` unset over setting `max_containers=1`, as the latter will prevent Modal from bringing up a replacement to gracefully shift traffic during a [rolling redeployment](https://modal.com/docs/guide/managing-deployments#deployment-strategies). ## Zero-to-one scaling Because Servers use a stateless reverse proxy between clients and containers, requests do not queue while waiting for a container like Function inputs would. This has a significant consequence for zero-to-one scaling. When a Server has no active containers, requests will be rejected with a 503 Service Unavailable status, which clients must handle. Zero-to-one scaling is still automatic, so the first request will trigger a container cold start, and the Server will handle additional incoming requests as soon as it is ready. ## Container lifecycle Server containers are not considered ready until the Server process is listening on the configured port, even if the startup methods have returned. Requests will be sent to other containers (or rejected with a 503) until the container is ready. Containers that do not become ready within `startup_timeout=` seconds will be terminated and marked as failed. While a Server container is active, Modal will send health checks to verify that its port is still listening. If the container fails too many consecutive health checks, it will be terminated and replaced. When containers are scaled down, they will stop receiving new requests, but they may continue processing any inflight requests for up to `exit_grace_period=` seconds. Subsequently, the container will be sent a SIGTERM to gracefully terminate all running processes and run any exit handlers (`@modal.exit()`). The process termination and exit handlers are given an additional 30s to complete, after which the container will receive a hard SIGKILL signal if it is still running. ## Request authentication Unlike Web Functions, Servers require [authentication](https://modal.com/docs/guide/webhook-proxy-auth) in requests by default, and the Server configuration must set `unauthenticated=True` to accept public web traffic. Without this setting, unauthenticated requests will be denied by Modal’s proxy with a 401 code and will not contribute to autoscaler accounting. To authenticate a request, pass a Proxy Token as a single `Authorization: Bearer wk-.ws-` header, or as separate `Modal-Key` and `Modal-Secret` headers. For Workspaces with [RBAC](https://modal.com/docs/guide/rbac) enabled, the Proxy Tokens must additionally be scoped to the Environment where the Server’s App is deployed. Valid tokens that are not scoped to the relevant Environment will be denied with a 403 code. ## Request routing The Server configuration includes a region specification for the proxy that routes requests to containers (`routing_region=`). The following routing regions are supported: `us-east` (default), `us-west`, `ca-central`, `eu-west`, `ap-south`, and `ap-southeast-2`. As a general rule, select the routing region that will be closest to your clients. It’s also possible to constrain container scheduling within the same region using `compute_region=`, although note that this incurs a [cost multiplier](https://modal.com/docs/guide/region-selection#pricing). The routing proxy additionally supports “sticky sessions”. If requests include a `Modal-Session-ID` header (which can be an arbitrary string), distinct requests that share a session ID will be handled by the same container. ## Operational features Beyond request queueing, Servers lack several other operational features afforded by the stateful input system used for Modal Functions, and they require the user’s client or server application layer to implement those features when desired. Serialization and deserialization of request data must be handled at the application layer. There are no built-in [retries](https://modal.com/docs/guide/retries) for failed requests (including requests that fail when their container is [preempted](https://modal.com/docs/guide/preemption) or crashes). Request [timeouts](https://modal.com/docs/guide/timeouts) cannot be customized within Modal and must be set by the client or server code. #### Web Functions # Web Functions This guide explains how to set up Web Functions with Modal. All deployed Modal Functions can be [invoked from any other Python application](https://modal.com/docs/guide/trigger-deployed-functions) using the Modal client library. We additionally provide multiple ways to expose your Functions over the web for non-Python clients. You can [turn any Python function into a Web Function](#simple-endpoints) with a single line of code, you can [serve a full app](#serving-asgi-and-wsgi-apps) using frameworks like FastAPI, Django, or Flask, or you can [serve anything that speaks HTTP and listens on a port](#non-asgi-web-servers). Below we walk through each method, assuming you're familiar with web applications outside of Modal. For a detailed walkthrough of basic Web Functions on Modal aimed at developers new to web applications, see [this tutorial](https://modal.com/docs/examples/basic_web). ## Simple endpoints The easiest way to make a Python function addressable over the web uses the [`@modal.fastapi_endpoint` decorator](https://modal.com/docs/sdk/py/latest/fastapi_endpoint): ```python image = modal.Image.debian_slim().pip_install("fastapi[standard]") @app.function(image=image) @modal.fastapi_endpoint() def f(): return "Hello world!" ``` This decorator wraps the Modal Function in a [FastAPI application](#how-do-web-functions-run-in-the-cloud). _Note: Prior to v0.73.82, this function was named `@modal.web_endpoint`_. ### Developing with `modal serve` You can run this code as an ephemeral App, by running the command ```shell modal serve server_script.py ``` Where `server_script.py` is the file name of your code. This will create an ephemeral App for the duration of your script (until you hit Ctrl-C to stop it). It creates a temporary URL that you can use like any other REST endpoint. This URL is on the public internet. The `modal serve` command will live-update an App when any of its supporting files change. Live updating is particularly useful when working with apps containing web endpoints, as any changes made to Web Function handlers will show up almost immediately, without requiring a manual restart of the app. ### Deploying with `modal deploy` You can also deploy your App and create a persistent Web Function in the cloud by running `modal deploy`: ### Passing arguments When using `@modal.fastapi_endpoint`, you can add [query parameters](https://fastapi.tiangolo.com/tutorial/query-params/) which will be passed to your Function as arguments. For instance ```python image = modal.Image.debian_slim().pip_install("fastapi[standard]") @app.function(image=image) @modal.fastapi_endpoint() def square(x: int): return {"square": x**2} ``` If you hit this with a URL-encoded query string with the `x` parameter present, the Function will receive the value as an argument: ``` $ curl https://modal-labs--web-function-square-dev.modal.run?x=42 {"square":1764} ``` If you want to use a `POST` request, you can use the `method` argument to `@modal.fastapi_endpoint` to set the HTTP verb. To accept any valid JSON object, [use `dict` as your type annotation](https://fastapi.tiangolo.com/tutorial/body-nested-models/?h=dict#bodies-of-arbitrary-dicts) and FastAPI will handle the rest. ```python image = modal.Image.debian_slim().pip_install("fastapi[standard]") @app.function(image=image) @modal.fastapi_endpoint(method="POST") def square(item: dict): return {"square": item['x']**2} ``` This creates an endpoint that takes a JSON body: ``` $ curl -X POST -H 'Content-Type: application/json' --data-binary '{"x": 42}' https://modal-labs--web-function-square-dev.modal.run {"square":1764} ``` This is often the easiest way to get started, but note that FastAPI recommends that you use [typed Pydantic models](https://fastapi.tiangolo.com/tutorial/body/) in order to get automatic validation and documentation. FastAPI also lets you pass data to Web Functions in other ways, for instance as [form data](https://fastapi.tiangolo.com/tutorial/request-forms/) and [file uploads](https://fastapi.tiangolo.com/tutorial/request-files/). ## How do Web Functions run in the cloud? Note that Web Functions, like everything else on Modal, only run when they need to. When you hit the URL the first time, it will boot up the container, which might take a few seconds. Modal keeps the container alive for a short period in case there are subsequent requests. If there are a lot of requests, Modal might scale up more containers running in parallel. For the shortcut `@modal.fastapi_endpoint` decorator, Modal wraps your function in a [FastAPI](https://fastapi.tiangolo.com/) application. This means that the [Image](https://modal.com/docs/guide/images) your Function uses must have FastAPI installed, and the Functions that you write need to follow its request and response [semantics](https://fastapi.tiangolo.com/tutorial). Web Functions can use all of FastAPI's powerful features, such as Pydantic models for automatic validation, typed query and path parameters, and response types. Here's everything together, combining Modal's abilities to run functions in user-defined containers with the expressivity of FastAPI: ```python import modal from fastapi.responses import HTMLResponse from pydantic import BaseModel image = modal.Image.debian_slim().pip_install("fastapi[standard]", "boto3") app = modal.App(image=image) class Item(BaseModel): name: str qty: int = 42 @app.function() @modal.fastapi_endpoint(method="POST") def f(item: Item): import boto3 # do things with boto3... return HTMLResponse(f"Hello, {item.name}!") ``` This Function would be called like so: ```bash curl -d '{"name": "Erik", "qty": 10}' \ -H "Content-Type: application/json" \ -X POST https://ecorp--web-demo-f-dev.modal.run ``` Or in Python with the [`requests`](https://pypi.org/project/requests/) library: ```python import requests data = {"name": "Erik", "qty": 10} requests.post("https://ecorp--web-demo-f-dev.modal.run", json=data, timeout=10.0) ``` ## Serving ASGI and WSGI apps You can also serve any app written in an [ASGI](https://asgi.readthedocs.io/en/latest/) or [WSGI](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface)-compatible web framework on Modal. ASGI provides support for async web frameworks. WSGI provides support for synchronous web frameworks. ### ASGI apps - FastAPI, FastHTML, Starlette For ASGI apps, you can create a function decorated with [`@modal.asgi_app`](https://modal.com/docs/sdk/py/latest/asgi_app) that returns a reference to your web app: ```python image = modal.Image.debian_slim().pip_install("fastapi[standard]") @app.function(image=image) @modal.concurrent(max_inputs=100) @modal.asgi_app() def fastapi_app(): from fastapi import FastAPI, Request web_app = FastAPI() @web_app.post("/echo") async def echo(request: Request): body = await request.json() return body return web_app ``` Now, as before, when you deploy this script as a Modal App, you get a URL for your app that you can hit: The `@modal.concurrent` decorator enables a single container to process multiple inputs at once, taking advantage of the asynchronous event loops in ASGI applications. See [this guide](https://modal.com/docs/guide/concurrent-inputs) for details. #### ASGI Lifespan While we recommend using [`@modal.enter`](https://modal.com/docs/guide/lifecycle-functions#enter) for defining container lifecycle hooks, we also support the [ASGI lifespan protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html). Lifespans begin when containers start, typically at the time of the first request. Here's an example using [FastAPI](https://fastapi.tiangolo.com/advanced/events/#lifespan): ```python import modal app = modal.App("fastapi-lifespan-app") image = modal.Image.debian_slim().pip_install("fastapi[standard]") @app.function(image=image) @modal.asgi_app() def fastapi_app_with_lifespan(): from fastapi import FastAPI, Request def lifespan(wapp: FastAPI): print("Starting") yield print("Shutting down") web_app = FastAPI(lifespan=lifespan) @web_app.get("/") async def hello(request: Request): return "hello" return web_app ``` ### WSGI apps - Django, Flask You can serve WSGI apps using the [`@modal.wsgi_app`](https://modal.com/docs/sdk/py/latest/wsgi_app) decorator: ```python image = modal.Image.debian_slim().pip_install("flask") @app.function(image=image) @modal.concurrent(max_inputs=100) @modal.wsgi_app() def flask_app(): from flask import Flask, request web_app = Flask(__name__) @web_app.post("/echo") def echo(): return request.json return web_app ``` See [Flask's docs](https://flask.palletsprojects.com/en/2.1.x/deploying/asgi/) for more information on using Flask as a WSGI app. Because WSGI apps are synchronous, concurrent inputs will be run on separate threads. See [this guide](https://modal.com/docs/guide/concurrent-inputs) for details. ## Non-ASGI web servers Not all web frameworks offer an ASGI or WSGI interface. For example, [`aiohttp`](https://docs.aiohttp.org/) and [`tornado`](https://www.tornadoweb.org/) use their own asynchronous network binding, while others like [`text-generation-inference`](https://github.com/huggingface/text-generation-inference) actually expose a Rust-based HTTP server running as a subprocess. For these cases, you can use the [`@modal.web_server`](https://modal.com/docs/sdk/py/latest/web_server) decorator to "expose" a port on the container: ```python @app.function() @modal.concurrent(max_inputs=100) @modal.web_server(8000) def my_file_server(): import subprocess subprocess.Popen("python -m http.server -d / 8000", shell=True) ``` Just like all Functions on Modal, this is only run on-demand. The function is executed on container startup, creating a file server at the root directory. When you hit the URL, your request will be routed to the file server listening on port `8000`. For `@modal.web_server` Functions, you need to make sure that the application binds to the external network interface, not just localhost. This usually means binding to `0.0.0.0` instead of `127.0.0.1`. See, for instance, our examples of how to serve [Streamlit](https://modal.com/docs/examples/serve_streamlit) and [vLLM](https://modal.com/docs/examples/vllm_inference) on Modal. ## Serve many configurations with parametrized functions Python functions that launch ASGI/WSGI apps or web servers on Modal cannot take arguments. One simple pattern for allowing client-side configuration is to use [Parametrized Functions](https://modal.com/docs/guide/parametrized-functions). Each different choice for the values of the parameters will create a distinct auto-scaling container pool. ```python @app.cls() @modal.concurrent(max_inputs=100) class Server: root: str = modal.parameter(default=".") @modal.web_server(8000) def files(self): import subprocess subprocess.Popen(f"python -m http.server -d {self.root} 8000", shell=True) ``` The values are provided in URLs as query parameters: ```bash curl https://ecorp--server-files.modal.run # use the default value curl https://ecorp--server-files.modal.run?root=.cache # use a different value curl https://ecorp--server-files.modal.run?root=%2F # don't forget to URL encode! ``` For details, see [this guide to parametrized functions](https://modal.com/docs/guide/parametrized-functions). ## WebSockets Functions annotated with `@modal.web_server`, `@modal.asgi_app`, or `@modal.wsgi_app` also support the WebSocket protocol. Consult your web framework for appropriate documentation on how to use WebSockets with that library. WebSockets on Modal maintain a single function call per connection, which can be useful for keeping state around. Most of the time, you will want to set your handler function to [allow concurrent inputs](https://modal.com/docs/guide/concurrent-inputs), which allows multiple simultaneous WebSocket connections to be handled by the same container. We support the full WebSocket protocol as per [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455), but we do not yet have support for [RFC 8441](https://www.rfc-editor.org/rfc/rfc8441) (WebSockets over HTTP/2) or [RFC 7692](https://datatracker.ietf.org/doc/html/rfc7692) (`permessage-deflate` extension). WebSocket messages can be up to 2 MiB each. ## Performance and scaling If you have no active containers when the Web Function receives a request, it will experience a "cold start". Consult the guide page on [cold start performance](https://modal.com/docs/guide/cold-start) for more information on when Functions will cold start and advice how to mitigate the impact. If your Function uses `@modal.concurrent`, multiple requests to the same URL may be handled by the same container. Beyond this limit, additional containers will start up to scale your App horizontally. When you reach the Function's limit on containers, requests will queue for handling. Each workspace on Modal has a rate limit on total operations. For a new account, this is set to 200 Function calls or HTTP requests per second, with a burst multiplier of 5 seconds. If you reach the rate limit, excess requests will return a [429 status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429), and you'll need to [get in touch](mailto:support@modal.com) with us about raising the limit. Web Function request bodies can be up to 4 GiB, and their response bodies are unlimited in size. ## Authentication Modal offers first-class Web Function protection via [proxy tokens](https://modal.com/docs/guide/webhook-proxy-auth). Proxy tokens protect Web Functions by requiring a key and secret combination to be passed in the `Modal-Key` and `Modal-Secret` headers. Modal works as a proxy, rejecting requests that aren't authorized to access your endpoint. We also support conventional techniques for securing web servers. ### Token-based authentication This is easy to implement in whichever framework you're using. For example, if you're using `@modal.fastapi_endpoint` or `@modal.asgi_app` with FastAPI, you can validate a Bearer token like this: ```python from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import modal image = modal.Image.debian_slim().pip_install("fastapi[standard]") app = modal.App("auth-example", image=image) auth_scheme = HTTPBearer() @app.function(secrets=[modal.Secret.from_name("my-web-auth-token")]) @modal.fastapi_endpoint() async def f(request: Request, token: HTTPAuthorizationCredentials = Depends(auth_scheme)): import os print(os.environ["AUTH_TOKEN"]) if token.credentials != os.environ["AUTH_TOKEN"]: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect bearer token", headers={"WWW-Authenticate": "Bearer"}, ) # Function body return "success!" ``` This assumes you have a [Modal Secret](https://modal.com/secrets) named `my-web-auth-token` created, with contents `{AUTH_TOKEN: secret-random-token}`. Now, the URL will return a 401 status code, except when you hit it with the correct `Authorization` header set (note that you have to prefix the token with `Bearer `): ```bash curl --header "Authorization: Bearer secret-random-token" https://modal-labs--auth-example-f.modal.run ``` ### Client IP address You can access the IP address of the client making the request. This can be used for geolocation, whitelists, blacklists, and rate limits. ```python from fastapi import Request import modal image = modal.Image.debian_slim().pip_install("fastapi[standard]") app = modal.App(image=image) @app.function() @modal.fastapi_endpoint() def get_ip_address(request: Request): return f"Your IP address is {request.client.host}" ``` #### Streaming endpoints # Streaming endpoints Modal `fastapi_endpoint`s support streaming responses using FastAPI's [`StreamingResponse`](https://fastapi.tiangolo.com/advanced/custom-response/#streamingresponse) class. This class accepts asynchronous generators, synchronous generators, or any Python object that implements the [_iterator protocol_](https://docs.python.org/3/library/stdtypes.html#typeiter), and can be used with Modal Functions! ## Simple example This simple example combines Modal's `@modal.fastapi_endpoint` decorator with a `StreamingResponse` object to produce a real-time SSE response. ```python import time def fake_event_streamer(): for i in range(10): yield f"data: some data {i}\n\n".encode() time.sleep(0.5) @app.function(image=modal.Image.debian_slim().pip_install("fastapi[standard]")) @modal.fastapi_endpoint() def stream_me(): from fastapi.responses import StreamingResponse return StreamingResponse( fake_event_streamer(), media_type="text/event-stream" ) ``` If you serve this Web Function and hit it with `curl`, you will see the ten SSE events progressively appear in your terminal over a ~5 second period. ```shell curl --no-buffer https://modal-labs--example-streaming-stream-me.modal.run ``` The MIME type of `text/event-stream` is important in this example, as it tells the downstream web server to return responses immediately, rather than buffering them in byte chunks (which is more efficient for compression). You can still return other content types like large files in streams, but they are not guaranteed to arrive as real-time events. ## Streaming responses with `.remote` A Modal Function wrapping a generator function body can have its response passed directly into a `StreamingResponse`. This is particularly useful if you want to do some GPU processing in one Modal Function that is called by a CPU-based web endpoint Modal Function. ```python @app.function(gpu="any") def fake_video_render(): for i in range(10): yield f"data: finished processing some data from GPU {i}\n\n".encode() time.sleep(1) @app.function(image=modal.Image.debian_slim().pip_install("fastapi[standard]")) @modal.fastapi_endpoint() def hook(): from fastapi.responses import StreamingResponse return StreamingResponse( fake_video_render.remote_gen(), media_type="text/event-stream" ) ``` ## Streaming responses with `.map` and `.starmap` You can also combine Modal Function parallelization with streaming responses, enabling applications to service a request by farming out to dozens of containers and iteratively returning result chunks to the client. ```python @app.function() def map_me(i): return f"segment {i}\n" @app.function(image=modal.Image.debian_slim().pip_install("fastapi[standard]")) @modal.fastapi_endpoint() def mapped(): from fastapi.responses import StreamingResponse return StreamingResponse(map_me.map(range(10)), media_type="text/plain") ``` This snippet will spread the ten `map_me(i)` executions across containers, and return each string response part as it completes. By default the results will be ordered, but if this isn't necessary pass `order_outputs=False` as keyword argument to the `.map` call. ### Asynchronous streaming The example above uses a synchronous generator, which automatically runs on its own thread, but in asynchronous applications, a loop over a `.map` or `.starmap` call can block the event loop. This will stop the `StreamingResponse` from returning response parts iteratively to the client. To avoid this, you can use the `.aio()` method to convert a synchronous `.map` into its async version. Also, other blocking calls should be offloaded to a separate thread with `asyncio.to_thread()`. For example: ```python @app.function(gpu="any", image=modal.Image.debian_slim().pip_install("fastapi[standard]")) @modal.fastapi_endpoint() async def transcribe_video(request): from fastapi.responses import StreamingResponse segments = await asyncio.to_thread(split_video, request) return StreamingResponse(wrapper(segments), media_type="text/event-stream") # Notice that this is an async generator. async def wrapper(segments): async for partial_result in transcribe_video.map.aio(segments): yield "data: " + partial_result + "\n\n" ``` ## Further examples - Complete code for the simple examples given above is available [in our modal-examples Github repository](https://github.com/modal-labs/modal-examples/blob/main/07_web_endpoints/streaming.py). - [An end-to-end example of streaming Youtube video transcriptions with OpenAI's whisper model.](https://github.com/modal-labs/modal-examples/blob/main/06_gpu_and_ml/openai_whisper/streaming/main.py) #### Web Function URLs # Web Function URLs This guide documents the behavior of URLs for [Web Functions](https://modal.com/docs/guide/webhooks) on Modal: automatic generation, configuration, programmatic retrieval, and more. ## Determine the Web Function URL from code Modal Functions with the [`fastapi_endpoint`](https://modal.com/docs/sdk/py/latest/fastapi_endpoint), [`asgi_app`](https://modal.com/docs/sdk/py/latest/asgi_app), [`wsgi_app`](https://modal.com/docs/sdk/py/latest/wsgi_app), or [`web_server`](https://modal.com/docs/sdk/py/latest/web_server) decorator are made available over the Internet when they are [`serve`d](https://modal.com/docs/cli/latest/serve) or [`deploy`ed](https://modal.com/docs/cli/latest/deploy) and so they have a URL. This URL is displayed in the `modal` CLI output and is available in the Modal [dashboard](https://modal.com/apps) for the Function. To determine a Function's URL programmatically, check its [`get_web_url()`](https://modal.com/docs/sdk/py/latest/Function#get_web_url) property: ```python @app.function(image=modal.Image.debian_slim().pip_install("fastapi[standard]")) @modal.fastapi_endpoint(docs=True) def show_url() -> str: return show_url.get_web_url() ``` For deployed Functions, this also works from other Python code! You just need to do a [`from_name`](https://modal.com/docs/sdk/py/latest/Function#from_name) based on the name of the Function and its [App](https://modal.com/docs/guide/apps): ```python notest import requests remote_function = modal.Function.from_name("app", "show_url") remote_function.get_web_url() == requests.get(handle.get_web_url()).json() ``` ## Auto-generated URLs By default, Modal Functions will be served from the `modal.run` domain. The full URL will be constructed from a number of pieces of information to uniquely identify the endpoint. At a high-level, Web Function URLs for deployed Apps have the following structure: `https://--