Docker in Sandboxes

Modal has preview support for running docker containers inside modal.Sandbox. This is intended to support coding agents who want to interact with development environments that include container images.

This functionality is enabled by creating Sandboxes with experimental_options={"enable_docker": True}.

Demo 

Run the following program with the Image Builder version set to version 2025.06 or later.

MODAL_IMAGE_BUILDER_VERSION=2025.06 python3 demo.py

The output will be like this:

Looking up modal.Sandbox app
Creating sandbox
Building docker image
--------------------------------
Running Docker image
 ________
< Hello! >
 --------
    \
     \
      \
                    ##         .
              ## ## ##        ==
           ## ## ## ## ##    ===
       /"""""""""""""""""\___/ ===
      {                       /  ===-
       \______ O           __/
         \    \         __/
          \____\_______/
import os
import tempfile

import modal

# Use the 2025.06 Modal Image Builder which avoids the need to install Modal client
# dependencies into the container image.

os.environ["MODAL_IMAGE_BUILDER_VERSION"] = "2025.06"


# Create an image for the parent Modal container.
# We install various Docker basics and a script to start the Docker daemon.
def create_modal_container_image(start_dockerd_filename: str):
    image = (
        modal.Image.from_registry("ubuntu:22.04")
        .env({"DEBIAN_FRONTEND": "noninteractive"})
        .apt_install(["wget", "ca-certificates", "curl", "net-tools", "iproute2"])
        .run_commands(
            [
                "install -m 0755 -d /etc/apt/keyrings",
                "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
                "chmod a+r /etc/apt/keyrings/docker.asc",
                'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo \\"${UBUNTU_CODENAME:-$VERSION_CODENAME}\\") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null',
                "mkdir /build",
            ]
        )
        .apt_install([
            "docker-ce=5:27.5.0-1~ubuntu.22.04~jammy",
            "docker-ce-cli=5:27.5.0-1~ubuntu.22.04~jammy",
            "containerd.io",
            "docker-buildx-plugin",
            "docker-compose-plugin"
        ])
        # Ensure that our runc installation is modern.
        # We need this relatively-recent runc patch to ensure reliable networking in Docker:
        # https://github.com/opencontainers/runc/commit/491326cdeb3762a8b5f926be9bb5ddd36115e31d.
        .run_commands(
            [
                "rm $(which runc)",
                "wget https://github.com/opencontainers/runc/releases/download/v1.3.0/runc.amd64",
                "chmod +x runc.amd64",
                "mv runc.amd64 /usr/local/bin/runc",
            ]
        )
        # gVisor doesn't support nftables yet (https://github.com/google/gvisor/issues/10510).
        # Explicitly ensure that we use iptables-legacy -- the non-nftables version of iptables.
        .run_commands(
            [
                "update-alternatives --set iptables /usr/sbin/iptables-legacy",
                "update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy",
            ]
        )
        .add_local_file(start_dockerd_filename, "/start-dockerd.sh", copy=True)
        .run_commands(["chmod +x /start-dockerd.sh"])
    )
    return image


start_dockerd_sh_content = """#!/bin/bash
set -xe -o pipefail

dev=$(ip route show default | awk '/default/ {print $5}')
if [ -z "$dev" ]; then
    echo "Error: No default device found."
    ip route show
    exit 1
else
    echo "Default device: $dev"
fi
addr=$(ip addr show dev "$dev" | grep -w inet | awk '{print $2}' | cut -d/ -f1)
if [ -z "$addr" ]; then
    echo "Error: No IP address found for device $dev."
    ip addr show dev "$dev"
    exit 1
else
    echo "IP address for $dev: $addr"
fi

echo 1 > /proc/sys/net/ipv4/ip_forward
iptables-legacy -t nat -A POSTROUTING -o "$dev" -j SNAT --to-source "$addr" -p tcp
iptables-legacy -t nat -A POSTROUTING -o "$dev" -j SNAT --to-source "$addr" -p udp

# gVisor doesn't support nftables yet (https://github.com/google/gvisor/issues/10510).
# Explicitly ensure that we use iptables-legacy -- the non-nftables version of iptables.
update-alternatives --set iptables /usr/sbin/iptables-legacy
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy

exec /usr/bin/dockerd --iptables=false --ip6tables=false -D"""


def main():
    print("Looking up modal.Sandbox app")
    app = modal.App.lookup("docker-test", create_if_missing=True)
    print("Creating sandbox")

    # Write the start-dockerd.sh content to a temporary local file.
    with tempfile.NamedTemporaryFile(mode="w", delete=True, encoding="utf-8") as start_dockerd_sh:
        print(f'Writing the "start dockerd" script to: {start_dockerd_sh.name}')
        start_dockerd_sh.write(start_dockerd_sh_content)
        start_dockerd_sh.flush()
        os.chmod(start_dockerd_sh.name, 0o755)

        with modal.enable_output():
            sb = modal.Sandbox.create(
                "/start-dockerd.sh",
                timeout=60 * 60,
                app=app,
                image=create_modal_container_image(start_dockerd_sh.name),
                experimental_options={"enable_docker": True},
            )

    # 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"]
    """
    with sb.open("/build/Dockerfile", "w") as f:
        f.write(dockerfile)

    print("Building docker image")
    p = sb.exec("docker", "build", "--network=host", "-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")

    # Get the Sandbox to run the built image and show 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()