Skip to content
Docs

Mount remote storage

Mount an external object store such as Amazon S3 into a sandbox and work with remote files as if they were local. Code in the sandbox reads and writes objects through a normal filesystem path instead of calling a storage API directly.

Vercel Sandbox supports FUSE (Filesystem in Userspace) drivers, which back the mount. Use a FUSE mount when you need to share large datasets across sandboxes, persist files to an external provider, or hand agents and tools a filesystem interface over object storage.

For persistent storage managed by Vercel, see Drives. Use a FUSE mount when your data must live in an external provider such as S3.

A FUSE driver exposes remote object storage as a directory inside the sandbox. Reads and writes to that directory are translated into calls against the remote store. Because FUSE runs as a system-privileged process, the install and mount commands run with sudo.

Outbound access from the mount still follows the sandbox firewall network policy. If you run untrusted code, restrict reachable destinations with a network policy.

This example uses Mountpoint for Amazon S3, the official FUSE driver for S3. Download and install the mount-s3 package, create a mount directory, then mount the bucket:

index.ts
import { Sandbox } from '@vercel/sandbox';
 
const sandbox = await Sandbox.create();
 
try {
  // Refresh the package index. The sandbox starts without one.
  await sandbox.runCommand({
    sudo: true,
    cmd: 'apt-get',
    args: ['update'],
  });
 
  // apt-get cannot install from a URL, so download the package first.
  await sandbox.runCommand({
    cmd: 'curl',
    args: [
      '-sL',
      '-o',
      '/tmp/mount-s3.deb',
      'https://s3.amazonaws.com/mountpoint-s3-release/latest/x86_64/mount-s3.deb',
    ],
  });
 
  // Install Mountpoint for Amazon S3. It pulls in its own FUSE dependency.
  await sandbox.runCommand({
    sudo: true,
    cmd: 'apt-get',
    args: ['install', '-y', '/tmp/mount-s3.deb'],
  });
 
  const MOUNT_DIR = '/mnt/s3';
 
  await sandbox.runCommand({
    sudo: true,
    cmd: 'mkdir',
    args: ['-p', MOUNT_DIR],
  });
 
  // Mount the bucket. Pass AWS credentials only to the mount-s3 command.
  await sandbox.runCommand({
    sudo: true,
    cmd: 'mount-s3',
    args: [process.env.S3_BUCKET_NAME, MOUNT_DIR, '--allow-other'],
    env: {
      AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
      AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
      AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
      AWS_REGION: process.env.AWS_REGION,
    },
  });
 
  // List the files in your bucket to confirm the mount.
  await sandbox.runCommand({
    cmd: 'ls',
    args: ['-la', MOUNT_DIR],
    stdout: process.stdout,
  });
} finally {
  await sandbox.stop();
}

After the mount succeeds, ls -la /mnt/s3 prints the objects in your bucket. Any command in the sandbox can now read from and write to /mnt/s3.

Passing credentials to mount-s3 exposes them inside the sandbox for the life of the process. Use a narrowly scoped, short-lived IAM role that grants access only to the bucket and actions you need, and prefer temporary credentials (AWS_SESSION_TOKEN) over long-lived keys. To keep AWS credentials out of the sandbox altogether, see Mount an S3 bucket without exposing credentials.

The mount used above passes AWS credentials into the sandbox, where any process can read them. When the sandbox runs untrusted code, or when multiple tenants share one bucket split by key prefix, you do not want credentials in the sandbox at all.

You can keep them out by combining the mount with requests proxying. The sandbox sends unsigned S3 requests, the firewall forwards them to a Vercel Function you control, and the function authorizes each request and signs it with Signature Version 4 before it reaches S3. AWS credentials exist only inside the function which decides what each sandbox can access.

The proxy uses defineSandboxProxy to verify that each request comes from one of your sandboxes, and OIDC federation to obtain AWS credentials, so it stores no static keys. Because the firewall appends the original request path and query string to the forwardURL, implement the proxy as a catch-all route. Deploy it in a project where AWS_ROLE_ARN points to an IAM role with access to your bucket:

app/api/s3-proxy/[...key]/route.ts
import { Sha256 } from '@aws-crypto/sha256-js';
import { SignatureV4 } from '@smithy/signature-v4';
import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
import { defineSandboxProxy } from '@vercel/sandbox/proxy';
 
const signer = new SignatureV4({
  service: 's3',
  region: process.env.AWS_REGION!,
  credentials: awsCredentialsProvider({
    roleArn: process.env.AWS_ROLE_ARN!,
    // The role's trust policy must allow this audience.
    // See /docs/oidc/aws#custom-audience.
    audience: 'sts.amazonaws.com',
  }),
  sha256: Sha256,
  // Unlike other AWS services, S3 paths must not be escaped again
  // when signing.
  uriEscapePath: false,
});
 
const handler = defineSandboxProxy(async (request, meta) => {
  // The request is already re-targeted at the original S3 URL.
  const url = new URL(request.url);
 
  // Authorize before signing. With path-style requests the path is
  // /<bucket>/<key>, so you can restrict each sandbox to its own key
  // prefix, for example one derived from meta.sandboxId.
 
  // Sign only headers that carry client intent. Forwarding the whole
  // incoming header set breaks SigV4 validation, because infrastructure
  // adds headers along the way that are not part of the signature.
  const SIGNED_PASSTHROUGH = [
    'content-type',
    'content-md5',
    'range',
    'if-match',
    'if-none-match',
    'if-modified-since',
    'if-unmodified-since',
  ];
  const headers: Record<string, string> = {
    host: url.host,
    'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
  };
  for (const [key, value] of request.headers) {
    if (key.startsWith('x-amz-') || SIGNED_PASSTHROUGH.includes(key)) {
      headers[key] = value;
    }
  }
 
  const body =
    request.method === 'GET' || request.method === 'HEAD'
      ? undefined
      : new Uint8Array(await request.arrayBuffer());
 
  const signed = await signer.sign({
    method: request.method,
    protocol: url.protocol,
    hostname: url.hostname,
    path: url.pathname,
    query: Object.fromEntries(url.searchParams),
    headers,
  });
 
  return fetch(url, {
    method: request.method,
    headers: signed.headers,
    body,
  });
});
 
// mount-s3 reads objects with GET and HEAD and writes them with PUT,
// POST, and DELETE, so expose the handler under all of them.
export {
  handler as GET,
  handler as HEAD,
  handler as PUT,
  handler as POST,
  handler as DELETE,
};

Create the sandbox, install Mountpoint while the network is still open, then lock the network policy so S3 traffic flows through your proxy before mounting:

index.ts
import { Sandbox } from '@vercel/sandbox';
 
const sandbox = await Sandbox.create();
 
try {
  // Refresh the package index. The sandbox starts without one.
  await sandbox.runCommand({
    sudo: true,
    cmd: 'apt-get',
    args: ['update'],
  });
 
  // apt-get cannot install from a URL, so download the package first.
  await sandbox.runCommand({
    cmd: 'curl',
    args: [
      '-sL',
      '-o',
      '/tmp/mount-s3.deb',
      'https://s3.amazonaws.com/mountpoint-s3-release/latest/x86_64/mount-s3.deb',
    ],
  });
 
  // Install Mountpoint for Amazon S3. It pulls in its own FUSE dependency.
  await sandbox.runCommand({
    sudo: true,
    cmd: 'apt-get',
    args: ['install', '-y', '/tmp/mount-s3.deb'],
  });
 
  const MOUNT_DIR = '/mnt/s3';
 
  await sandbox.runCommand({
    sudo: true,
    cmd: 'mkdir',
    args: ['-p', MOUNT_DIR],
  });
 
  // Route all S3 traffic through the signing proxy. No other egress is
  // allowed. Add "*": [] to keep general Internet access for other domains.
  await sandbox.update({
    networkPolicy: {
      allow: {
        's3.amazonaws.com': [
          { forwardURL: 'https://my-project.vercel.app/api/s3-proxy' },
        ],
      },
    },
  });
 
  // Mount without credentials. --no-sign-request skips signing in the
  // sandbox, and --force-path-style keeps the request host on
  // s3.amazonaws.com so it matches the domain rule above.
  await sandbox.runCommand({
    sudo: true,
    cmd: 'mount-s3',
    args: [
      process.env.S3_BUCKET_NAME,
      MOUNT_DIR,
      '--allow-other',
      '--no-sign-request',
      '--force-path-style',
      '--endpoint-url',
      'https://s3.amazonaws.com',
    ],
  });
 
  // List the files in your bucket to confirm the mount.
  await sandbox.runCommand({
    cmd: 'ls',
    args: ['-la', MOUNT_DIR],
    stdout: process.stdout,
  });
} finally {
  await sandbox.stop();
}

The sandbox never receives AWS credentials, and every request to the bucket passes through your function. Since the proxy example forwards each request unchanged, add your own authorization checks in the handler before signing. For example, restrict each sandbox to its own key prefix.

Mountpoint is specific to S3, but the same pattern works with any FUSE driver. Install the driver with your image's package manager, create a mount directory, then run the driver's mount command with sudo. On the default Ubuntu-based managed images, refresh the package index with apt-get update before installing. Legacy Amazon Linux runtimes use dnf instead. The same approach covers other object stores, network filesystems, and custom mounts, so a sandbox can persist files to more than one storage provider in a single session.

Some providers sync content into the sandbox instead of serving it through a FUSE driver. For Box content, see Box Mount.

Last updated September 23, 2026

Was this helpful?

supported.