Skip to content

Containers

One directory per container, each defined by a single container.toml.

Commands

scripts/new-container NAME      # scaffold a new one
scripts/deploy NAME             # build + run it once
scripts/deploy NAME -c 'CMD'    # override [run] command for one run
scripts/deploy --list           # what is here

Both resolve the repo through their own symlink, so they can live on PATH:

ln -s "$PWD/scripts/new-container" ~/.local/bin/new-container
ln -s "$PWD/scripts/deploy"        ~/.local/bin/modal-deploy

.claude/skills/modal-containers/ documents the whole workflow for Claude.

Each container is a directory holding a container.toml, and that file is the whole definition -- base image, resources, what to run, whether there is a nix devShell. containers/_loader.py turns it into a modal.Image and a modal.App; container.py is a fixed stub that wires the two together and is not meant to be edited. the container.toml reference documents every key.

Modal itself has no such convention -- modal bootstrap only stamps out three fixed ML demos -- so this is local to this repo.

containers/hello is the worked example: the arch-nix image, a devShell with hello, jq, ripgrep, git and python3, and an app that prints where each of those resolved. It runs as a Sandbox on a real VM.

hello from the devShell
python   3.14.7 at /nix/store/d64q19q1...-python3-3.14.7/bin/python3
host     modal (x86_64)
devshell impure
  hello    /nix/store/xl1h9i29...-hello-2.12.3/bin/hello

Layer order is the whole performance story

The devShell is warmed at build time -- nix develop --command true -- so its store paths bake into the image and a container starts straight into the app. Everything in that closure is binary-cached, so it is a one-time download, but only if the layer survives.

It survives because _loader.py copies flake.nix and flake.lock, warms, and only then copies the source. Copy the source first and every edit to any file invalidates the warm, and the whole closure is fetched again on every build. Measured on hello, whose shell is ~3000 store paths:

build result
cold, or flake.nix changed 55s warm step
source edited, flake untouched warm cached; COPY . / only, 6s
nothing changed no build at all, 18s end to end

git alone is 87 paths and 404MB, python3 another 23 and 220MB. That is a reason to get the layer order right, not a reason to keep them out of the shell.

The container re-imports everything

Modal re-imports the entrypoint inside the container, at /root, which gets none of the workdir copies. So _loader.py ships three things there explicitly: itself via add_local_python_source, the container.toml it reads, and -- since none of the local tree exists out there -- a MODAL_TASK_ID check that skips validation and image-building on the remote pass.

Sandboxes get the VM

containers/hello is a sandbox container -- runtime = "sandbox" -- so it runs on a real VM. Verified: kernel 6.12.8+, and nix build works there with no LD_PRELOAD at all.

scripts/deploy hello
scripts/deploy hello -c 'cat /proc/version'

The image is still built under gVisor, so [nix] shim stays on for the build and is dropped from the run-time command. For a build that should run and then exit, a sandbox is the better fit than a function: the command is the sandbox's own process, so it dies when the build does.

[nix] shim

Warming a devShell means building nix-shell-env, which is exactly the gVisor pty bug above. [nix] flake = true therefore requires shim = true, and the loader refuses the combination up front rather than failing ten minutes into a build.