Mountable

Mount tutorials

Copy-paste Mountable mount steps per sandbox. E2B is a production mount; the other recipes are desk research from 2026-10-13.

Read the comparison before you copy a block. One label applies to every recipe except E2B: not run. The commands record the prerequisites from vendor docs retrieved on 2026-10-13. They are not a report that Mountable mounted there.

The ticket flow itself is the one on Mount from a sandbox and Agents. Finish billing setup first. Creating a session without a payment method returns payment_required.

Shared ticket flow

On the backend, where MOUNTABLE_API_KEY is allowed to live:

SESSION_JSON=$(mktemp)
mountable ticket create "$FS_ID" --idempotency-key "$JOB_ID" --json > "$SESSION_JSON"

Check the command succeeded. Read ticket from that private file and pass only that string into the sandbox as MOUNTABLE_TICKET. Delete the file. Do not print the ticket, put it in an image, or pass MOUNTABLE_API_KEY to the sandbox. Do not pipe ticket create --json into mount.

In the sandbox, after the CLI is on PATH and the mount directory exists:

mkdir -p /mnt/work
printf '%s\n' "$MOUNTABLE_TICKET" | mountable mount --ticket-stdin --json /mnt/work &

Wait for the JSON mounted event before using /mnt/work. The mount process stays running. install.sh does not change PATH. A non-root install lands in ~/.local/bin. As root, install with MOUNTABLE_INSTALL_DIR=/usr/local/bin so later commands find the binary. If the mount must run under sudo, put the whole pipeline in one root shell. sudo on only one side of the pipe drops the ticket.

mount --json is the current contract. The recorded E2B passes below did not use --json.

E2B

Production mount. Debian 12 default template, default non-root user, CLI 0.2.0 on 2026-10-06 and CLI 0.3.0 on 2026-10-07. The check installed the CLI with install.sh, did not install fuse3, wrote and read back 8 MiB, and saw the mount fail 2–4 seconds after revocation. CLI 0.4.0 has not been re-run. Do not switch this recipe to root. E2B's own gcsfuse guide uses sudo; that is not the Mountable path that passed.

What those runs executed inside the sandbox, with the ticket file written by the host. The checker sleeps one second so the mount opens that file, then removes it:

curl -fsSL https://mountable.io/install.sh | sh
mkdir -p /home/user/work
nohup sh -c '~/.local/bin/mountable mount --ticket-stdin /home/user/work < /home/user/ticket' > /home/user/mount.log 2>&1 &
sleep 1
rm -f /home/user/ticket

The same non-root user, with the current CLI's --json form and a ticket in the environment instead of a file:

curl -fsSL https://mountable.io/install.sh | sh
mkdir -p /home/user/work
printf '%s\n' "$MOUNTABLE_TICKET" | "$HOME/.local/bin/mountable" mount --ticket-stdin --json /home/user/work &

The --json line was not part of the recorded passes.

Default egress is open. If you set domain allowOut rules, add CIDR rules for ports 8889 and 18889. Leave network.rules TLS transforms off for Mountable traffic.

Desk research, 2026-10-13. Not run. Create the sandbox with runtime="vm". Modal's docs name that runtime for mounting FUSE filesystems. Omitting it is not this recipe. Root inside the VM, and whether every plan offers the VM runtime, were unverified. GPU sandboxes are the wrong runtime.

Image sketch from Modal's image API as documented then. The Sandbox.create call is the part that must not lose runtime="vm":

import modal
 
image = (
    modal.Image.debian_slim()
    .apt_install("fuse3", "curl", "ca-certificates")
    .run_commands(
        "curl -fsSL https://mountable.io/install.sh | MOUNTABLE_INSTALL_DIR=/usr/local/bin sh"
    )
)
# `app` is the Modal app you already use. `runtime="vm"` is required.
sandbox = modal.Sandbox.create(app=app, image=image, runtime="vm")

Pass MOUNTABLE_TICKET into the VM and run the shared mount command there. Do not put the API key in the sandbox. If the VM's user is not root, the mount still needs a root shell; that user was not checked. fuse3 is installed because the unprivileged helper is unproven, not because a mount showed it was required.

Leave outbound access at the default, or add CIDR rules for 8889 and 18889. A domain allowlist does not cover those ports. Do not add a secret-injection rule for mountable.io.

Daytona

Desk research, 2026-10-13. Not run. Use a Tier 3 or Tier 4 organisation. Tier 1 and Tier 2 restrict egress to essential services and reject a sandbox-level override. mountable.io is not in that essential set, so those tiers are likely blocked before FUSE matters.

Daytona documents a pre-built snapshot and a runtime install. This is the runtime path. It was not executed:

sudo apt-get update && sudo apt-get install -y fuse3 curl ca-certificates
curl -fsSL https://mountable.io/install.sh | sudo MOUNTABLE_INSTALL_DIR=/usr/local/bin sh
sudo --preserve-env=MOUNTABLE_TICKET sh -c 'mkdir -p /mnt/work; printf "%s\n" "$MOUNTABLE_TICKET" | mountable mount --ticket-stdin --json /mnt/work' &

The API key stays on the backend that minted the ticket. Whether domainAllowList allows 8889 and 18889 was unverified; on Tier 3 and 4 the default is full internet access, which is the path this recipe assumes.

Fly Machines

Desk research, 2026-10-13. Not run. Support lives in Fly forum posts, not the formal docs. A 2026-08 report found /dev/fuse and CAP_SYS_ADMIN. Staff confirmed the FUSE module stays. Run as root. No device flag exists, because a Machine is a microVM.

FROM debian:12
RUN apt-get update && apt-get install -y fuse3 curl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://mountable.io/install.sh | MOUNTABLE_INSTALL_DIR=/usr/local/bin sh

Start the Machine with MOUNTABLE_TICKET in the environment and MOUNTABLE_API_KEY absent, then run the shared mount command as root before the agent uses /mnt/work. If a Fly network policy is attached, allow TCP 443, 8889 and 18889. The first rule makes that direction default-deny, and the policy has no CIDR field.

Vercel Sandbox

Desk research, 2026-10-13. Not run. Vercel documents FUSE workloads in a Firecracker microVM and tells you to run them with sudo. /dev/fuse is not named. Keep networkPolicy at allow-all unless you have probed 8889 and 18889 under the policy you will ship.

sudo apt-get update && sudo apt-get install -y fuse3 curl ca-certificates
curl -fsSL https://mountable.io/install.sh | sudo MOUNTABLE_INSTALL_DIR=/usr/local/bin sh
sudo --preserve-env=MOUNTABLE_TICKET sh -c 'mkdir -p /mnt/work; printf "%s\n" "$MOUNTABLE_TICKET" | mountable mount --ticket-stdin --json /mnt/work' &

Keep transform and forwardURL off for Mountable hostnames. Custom SNI allowlists were unverified for ports other than 443.

Cloudflare Containers

Desk research, 2026-10-13. Not run. Cloudflare documents FUSE inside the container (an R2 example). There is no privilege flag to add. Leave enableInternet at the default true, and leave interceptHttps off. wrangler dev is not evidence: local dev has been reported without /dev/fuse.

The container's entrypoint is PID 1, so it has to wait on the mount process. This loop has not been deployed:

mkdir -p /mnt/work
printf '%s\n' "$MOUNTABLE_TICKET" | /usr/local/bin/mountable mount --ticket-stdin --json /mnt/work > /tmp/mount.log 2>&1 &
pid=$!
for _ in $(seq 1 300); do
  grep -q '"event":"mounted"' /tmp/mount.log && break
  grep -q '"error"' /tmp/mount.log && exit 1
  sleep 0.2
done
grep -q '"event":"mounted"' /tmp/mount.log || exit 1
wait "$pid"

Build fuse or fuse3, curl and the CLI into the image with MOUNTABLE_INSTALL_DIR=/usr/local/bin. Inject the ticket as a secret. Do not bake the API key into the image. With enableInternet = false, ports 8889 and 18889 do not exist to allow.

Docker

Prerequisites only, checked 2026-10-13. The mount was not completed. Docker's documented FUSE example needs both flags. Either flag alone failed in that example. A default docker run has neither /dev/fuse nor CAP_SYS_ADMIN.

On that date a container started with both flags could call the Mountable API and was refused payment_required because the check organisation had no payment method. The FUSE mount never started. A non-root mount with only the device was also not completed. --security-opt apparmor:unconfined is an untested fallback, not part of Docker's example, so it is not in the command below.

docker run --rm -i \
  --device /dev/fuse --cap-add SYS_ADMIN \
  -e MOUNTABLE_TICKET \
  your-image \
  sh -c 'mkdir -p /mnt/work; printf "%s\n" "$MOUNTABLE_TICKET" | mountable mount --ticket-stdin --json /mnt/work'

Mint the ticket on the host. Do not pass MOUNTABLE_API_KEY into docker run. The image needs the CLI on PATH (MOUNTABLE_INSTALL_DIR=/usr/local/bin at build). This command runs the mount in the foreground; the container stays up while the mount does. Compose equivalents are devices: ["/dev/fuse:/dev/fuse"] and cap_add: ["SYS_ADMIN"].

GitHub Actions

Desk research, 2026-10-13. Not run. Use ubuntu-24.04 or ubuntu-latest. Do not use ubuntu-slim: GitHub documents that label as an unprivileged container that cannot mount filesystems. Whether the VM image has /dev/fuse is unverified. Mountable's own workflows run on a self-hosted pool, so they do not answer this.

Two jobs keep the API key off the mounting VM. Job outputs can still show up in Actions diagnostics; do not enable step-debug logging. Mask the ticket. The mount step waits for mounted and unmounts before it ends. A hosted runner stops leftover background processes when the step exits, so a trailing & would tear the mount down and the step could still succeed. This workflow has not been executed:

name: mountable-mount
on: workflow_dispatch
jobs:
  mint:
    runs-on: ubuntu-24.04
    outputs:
      ticket: ${{ steps.mint.outputs.ticket }}
    steps:
      - name: Mint a ticket on the backend
        id: mint
        env:
          MOUNTABLE_API_KEY: ${{ secrets.MOUNTABLE_API_KEY }}
          FS_ID: ${{ secrets.MOUNTABLE_FILESYSTEM_ID }}
        run: |
          curl -fsSL https://mountable.io/install.sh | sh
          TICKET=$(~/.local/bin/mountable ticket create "$FS_ID" --idempotency-key "${{ github.run_id }}")
          echo "::add-mask::$TICKET"
          echo "ticket=$TICKET" >> "$GITHUB_OUTPUT"
  mount:
    needs: mint
    runs-on: ubuntu-24.04
    steps:
      - name: Mount from the ticket
        env:
          MOUNTABLE_TICKET: ${{ needs.mint.outputs.ticket }}
        run: |
          sudo apt-get update && sudo apt-get install -y fuse3
          curl -fsSL https://mountable.io/install.sh | sudo MOUNTABLE_INSTALL_DIR=/usr/local/bin sh
          sudo --preserve-env=MOUNTABLE_TICKET sh -c 'mkdir -p /mnt/work; printf "%s\n" "$MOUNTABLE_TICKET" | mountable mount --ticket-stdin --json /mnt/work' > "$RUNNER_TEMP/mount.log" 2>&1 &
          pid=$!
          for _ in $(seq 1 300); do
            grep -q '"event":"mounted"' "$RUNNER_TEMP/mount.log" && break
            grep -q '"error"' "$RUNNER_TEMP/mount.log" && exit 1
            sleep 0.2
          done
          grep -q '"event":"mounted"' "$RUNNER_TEMP/mount.log" || exit 1
          # Commands that read and write /mnt/work go here.
          sudo mountable unmount /mnt/work
          wait "$pid"

A private network on a larger runner can drop 8889 and 18889 or intercept TLS. The intercepting CA would have to be in a custom image, which this recipe does not use. Leave the runner on normal egress until a real job has probed all three ports.