Modal (hosted)¶
Note
Experimental: this can change or be removed in a minor release of this provider. See Stable and experimental features.
ModalSandboxBackend runs each
sandbox in Modal, provisioned over the API. Of the backends that ship with the
provider, this is the managed one to use in production, and with
OpenSandbox, OpenShell,
Boat and Islo one of the five a
worker on Kubernetes can use: nothing has to be installed on the worker, model-written code never executes on the worker host, and Modal reclaims
a sandbox at its own lifetime whether or not the worker survives. It needs the
modal extra and Modal credentials, from a modal connection or the worker
environment, as under Quick start.
Constructor parameters:
modal_conn_id:modalconnection the token and, optionally, the Modalenvironmentcome from. Default"modal_default";Noneuses the worker’s credentials without looking for a connection. How a missing or partial connection resolves is on the Modal connection page. A credential problem fails the task, except in the toolset’s own teardown, which logs it so a finished run is not failed. The connection type comes from the Modal provider, which themodalextra installs and which needs Airflow 3.image: Registry tag for the sandbox image, or a preparedmodal.Imagecarrying pre-installed packages. Default"python:3.12-slim".app_name: Modal app the sandboxes are created under. Default"airflow-sandbox".create_app_if_missing: Create that app if it does not exist. DefaultTrue.sandbox_timeout: Maximum lifetime of a sandbox in seconds. Default3600. Modal’s own default is 300, which is below a plausible agent run.idle_timeout: Seconds of inactivity after which Modal reclaims the sandbox, orNoneto rely onsandbox_timeoutalone. DefaultNone; see Size the sandbox and its timeouts.workdir: Working directory for commands, created if the image lacks it. Default"/workspace". It is a starting directory, not a jail: absolute paths and..are passed through, and commands run as root, so the model can read and write anywhere in the sandbox filesystem. The sandbox boundary is what contains that.cpu,memory,gpu,region,cloud: passed through to Modal.Nonelets Modal choose. An unrecognizedregionorcloudfails the task rather than falling back.tags: Extra Modal tags on every sandbox, e.g.{"dag_id": "my_dag"}. The backend’s ownairflow_keys overwrite a tag of the same name.egress_enforcement:"strict"(default) or"sni". See below.
A sandbox can be provisioned by one task and used by another. Modal finds a
sandbox by id from any process, so this backend implements
AttachableSandboxBackend: a
@task calls create and later destroy, and a SandboxToolset with
attach_to uses the sandbox in between. The ownership rules ride on Modal tags,
airflow_owner from SandboxSpec.owner, airflow_holder while a run holds
the sandbox, airflow_expires_at so the attaching side knows the clock and
airflow_network so it knows the policy, and Sandbox.list(tags=...) finds
them. Reading tags back needs modal>=1.5.2, which is the extra’s floor. How
to use it is on Configuration.
Network policy. block_network=True maps exactly onto Modal’s own
block_network, which drops all outbound traffic including DNS. A spec that
names allow_egress_to is refused by default, because Modal cannot combine
an allowlist with block_network at all, and its hostname allowlist is enforced
by matching the name in the TLS handshake, which means:
TLS on port 443 to a listed host connects; any other host is refused at once.
Non-TLS traffic is dropped, not refused. A plain HTTP connection to a listed host stalls until the client gives up, around two minutes of TCP retries for one address, so with the 60 s default command budget the model reads
[timed out after 60s]and concludes its command was slow, never that the network stopped it.The destination address is not part of the decision. A connection opened to an unrelated address while presenting a listed name is routed to the listed host and answered by it: connecting to
8.8.8.8:443withpypi.orgin the handshake returns the certificate and content ofpypi.org. The allowlist is a name-routed egress proxy, not a filter on where packets may go.DNS resolution stays open for every hostname, listed or not, against authoritative servers outside Modal. A freshly generated label under a domain the operator controls resolves and returns its answer, so this is a two-way channel.
A host that shares a TLS endpoint with a listed one can be reached by presenting the listed name in the handshake and the other in the request. With
pypi.orgas the only allowed host, a TLS session opened tofiles.pythonhosted.orgwhile presentingpypi.orgwas allowed through and answered. Other tenants of the same CDN returned421 Misdirected Request; it is co-tenancy of the same TLS endpoint that matters, which you cannot check from outside and which can change without notice.
So the allowlist says which name a TLS session may be routed to, and nothing else.
Pass ModalSandboxBackend(egress_enforcement="sni") to say you accept that and
have the allowlist applied:
SandboxToolset(
ModalSandboxBackend(egress_enforcement="sni"),
spec=SandboxSpec(block_network=True, allow_egress_to=["pypi.org", "files.pythonhosted.org"]),
)
Entries must be bare hostnames or one leading *. label; a URL, a host:port,
an address or a single-label name is refused, because Modal applies the list without
checking it and any of those would silently match nothing.
An address allowlist is enforced properly, and needs no opt-in.
allow_egress_to_cidrs maps onto Modal’s outbound_cidr_allowlist, which
decides on the destination address for any port and protocol. Measured on
2026-09-22 with ["1.1.1.1/32"]: the listed address connected on 443 and on 53,
an unlisted address timed out on both, and block_network with the list set was
refused at create, as with the hostname list. This is the right mode for one
service at a fixed public address, which is the case the hostname list serves
worst. It cannot serve a package registry behind a CDN, whose addresses rotate
faster than a sandbox lives.
SandboxToolset(
ModalSandboxBackend(),
spec=SandboxSpec(block_network=True, allow_egress_to_cidrs=["203.0.113.0/24", "198.51.100.7/32"]),
)
Four things to know about it:
The address has to be public, and IPv4. Private ranges are unreachable from a Modal sandbox whatever the allowlist says: measured, connections to
10.20.0.1,172.16.0.1,192.168.1.1and the cloud metadata address timed out both under an open network and with those ranges on the allowlist, so a service on your own private network cannot be reached this way; it needs a public address, or a way onto your network that Modal provides and this backend does not configure. Modal’s allowlist also rejects IPv6 ranges outright, and the sandbox has no IPv6 route, so an IPv6 entry is refused here with the reason.Hostnames still resolve. Modal’s own resolver inside the sandbox answers every lookup, so
pypi.orgresolves to its addresses and a connection to them then times out. The tool description tells the model this so a successful lookup is not read as a reachable host. DNS is therefore still a channel out, as it is under the hostname list; onlyblock_network=Truewith no allowlist closes it.Entries are canonical CIDR. A bare address is written as
/32. A range with host bits set, such as203.0.113.1/24, is refused rather than widened to203.0.113.0/24, because that is not what was written. A hostname, a URL or ahost:portis refused, since Modal would accept it and match nothing.0.0.0.0/0and::/0are refused too: an allowlist of every address is an open network, andblock_network=Falseis how to ask for one.Combining the two lists weakens the address one. Modal applies them together, and traffic matching either passes. Measured, adding
pypi.orgto the hostname list beside["1.1.1.1/32"]made a TCP connection to8.8.8.8:443succeed, because port 443 is then routed by handshake name for every address. So a combined spec has the address list’s guarantee on every port except 443, and the hostname list’s caveats there. The hostname half keeps itsegress_enforcement="sni"opt-in when combined, and the backend logs a warning at create naming the weakening.
What the image needs. write_file and list_directory use Modal’s own
filesystem API, served by a helper Modal injects into the sandbox, so they need
nothing from the image. read_file deliberately does not: Modal’s read API takes
no length, so it cannot honor a read budget, and a single call on a file that
streams without end (/dev/zero, a FIFO, a procfs entry) would pull it into
worker memory unbounded. That tool runs the base class’s shell implementation,
which caps the read inside the guest, and the image needs stat, head and
base64. Any Debian or Ubuntu based image, including python:*-slim, has
them.
Symlinks. Because write_file goes through the native API, writing to a path
that is a symlink replaces the link with a regular file and leaves the original
target untouched, where a shell redirect would follow the link.