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/ticketThe 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.
Modal
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 shStart 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.