Mount an S3 bucket at a path in a sandbox so that any process reads and writes
it like a local directory. A FUSE daemon serves the path, keeps a file cache on
the sandbox disk, and uploads changes to the bucket in the background. Each file
is one object in the bucket. Paths such as node_modules, .cache, and
target stay on the sandbox disk and never reach the bucket.
This guide uses rclone as the FUSE daemon and Latitude.sh Standard object storage in San Jose as the bucket. Freestyle peers directly with Latitude.sh object storage in San Jose: traffic between a sandbox and a San Jose bucket stays on that link instead of crossing the public internet, and a cache-miss round trip is a few milliseconds. Any S3-compatible bucket works with the same commands and a different endpoint.
Create the Bucket and Access Key
In the Latitude.sh dashboard, create a bucket with storage class Standard
and region San Jose. The S3 endpoint for that region is
s3.us-west-2.storage.sh. Bucket names are global and use lowercase letters,
numbers, dots, and hyphens.
Under Access keys, create a key scoped to that bucket with read-write access. The secret is shown once. Keep the key ID and secret in your controller’s secret store; the sandbox receives them in the next step.
Boot a Sandbox and Install the Daemon
Install the SDK in your controller:
pnpm add freestyle@latestbun add freestyle@latestnpm install freestyle@latestyarn add freestyle@latest Boot an Ubuntu sandbox with public egress. The guest kernel has FUSE built in;
fuse3 and rclone are not in the base snapshot and are installed here. The
rclone install script fetches the current release; Ubuntu’s rclone package
also works but is older.
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
const { vm, vmId } = await freestyle.vms.create({
snapshotId: "freestyle/ubuntu",
slug: "s3-workspace",
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
idleTimeoutSeconds: null,
});
const install = await vm.exec(
"sudo apt-get update -qq && sudo apt-get install -y -qq fuse3 && " +
"curl -fsSL https://rclone.org/install.sh | sudo bash && " +
"echo user_allow_other | sudo tee -a /etc/fuse.conf",
);
if (install.statusCode !== 0) throw new Error(install.stderr);
console.log((await vm.exec("rclone version | head -1")).stdout); // rclone v1.7x.x
user_allow_other lets a mount owned by ubuntu be read by processes running
as other users, including root services and Docker containers.
Configure the Remote
Write an rclone config that names the bucket. provider: Other is correct for
Latitude.sh. The file is readable only by ubuntu, the user rclone runs as.
const config = `[latitude]
type = s3
provider = Other
access_key_id = ${process.env.LATITUDE_ACCESS_KEY_ID}
secret_access_key = ${process.env.LATITUDE_SECRET_ACCESS_KEY}
endpoint = https://s3.us-west-2.storage.sh
region = us-west-2
acl = private
`;
await vm.exec("mkdir -p /home/ubuntu/.config/rclone");
await vm.fs.writeTextFile("/home/ubuntu/.config/rclone/rclone.conf", config);
await vm.exec("chmod 600 /home/ubuntu/.config/rclone/rclone.conf");
const ls = await vm.exec("rclone lsd latitude:");
if (ls.statusCode !== 0) throw new Error(ls.stderr);
console.log(ls.stdout); // one line per bucket
Replace latitude: with your bucket name in the commands below, in the form
latitude:my-bucket.
Sign Requests at the Edge Instead
A TLS rule with an
s3 transform
signs the sandbox’s storage requests at the edge. The rclone config holds
placeholder keys, and the sandbox can reach only one prefix of the bucket.
Requests outside the prefix return 403.
Create the rule in your controller:
await freestyle.tls.rules.create({
action: "allow",
domain: "s3.us-west-2.storage.sh",
source: { vmId },
destination: { public: true },
transform: [{
s3: {
region: "us-west-2",
credentials: {
accessKeyId: process.env.LATITUDE_ACCESS_KEY_ID!,
secretAccessKey: process.env.LATITUDE_SECRET_ACCESS_KEY!,
},
scope: {
bucket: "my-bucket",
prefix: `workspaces/${vmId}/`,
operations: [
"GetObject", "HeadObject", "PutObject", "DeleteObject", "ListObjectsV2",
"CreateMultipartUpload", "UploadPart", "ListParts",
"CompleteMultipartUpload", "AbortMultipartUpload", "ListMultipartUploads",
],
},
},
}],
});
End the prefix with /: workspaces/vm-1 also matches workspaces/vm-10.
rclone uploads files over 200 MiB in parts, which uses the multipart
operations.
Write the rclone config with placeholder keys in place of the one above:
const config = `[latitude]
type = s3
provider = Other
access_key_id = placeholder
secret_access_key = placeholder
endpoint = https://s3.us-west-2.storage.sh
region = us-west-2
list_version = 2
no_check_bucket = true
`;
list_version = 2lists with ListObjectsV2. Without it, rclone lists with ListObjects v1, which needsListObjectsin the rule.no_check_bucket = truestops rclone from checking or creating the bucket before uploads.- The config has no
aclline. The rule rejects ACL headers.
In the mount unit, mount the prefix with a trailing / and add
--no-modtime:
ExecStart=/usr/bin/rclone mount latitude:my-bucket/workspaces/<vmId>/ /home/ubuntu/workspace \
--no-modtime \
...
Without the trailing /, rclone sends a HeadObject for workspaces/<vmId>,
which is outside the prefix. --no-modtime stops rclone from updating
modification times on uploaded files with CopyObject.
The rule rejects CopyObject, which rclone uses for renames. In this mount:
- Renaming a file that has not uploaded yet works.
- Renaming a file that has already uploaded, or renaming any directory, fails
with
Input/output error.
Use the access key in the rclone config when the workload renames uploaded files or directories.
Mount With a Local Cache
Run the mount as a systemd service so it survives shell exits and starts on
boot. --vfs-cache-mode full caches files on the sandbox disk and serves
reads and writes from that cache. Without it, random writes and in-place edits
fail on S3.
const unit = `[Unit]
Description=S3 workspace mount
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
User=ubuntu
Group=ubuntu
ExecStartPre=/bin/mkdir -p /home/ubuntu/workspace /var/cache/rclone
ExecStart=/usr/bin/rclone mount latitude:my-bucket /home/ubuntu/workspace \\
--cache-dir /var/cache/rclone \\
--vfs-cache-mode full \\
--vfs-cache-max-size 20G \\
--vfs-cache-max-age 720h \\
--vfs-write-back 5s \\
--dir-cache-time 1h \\
--transfers 16 \\
--allow-other \\
--umask 002 \\
--rc --rc-addr 127.0.0.1:5572 --rc-no-auth \\
--log-level INFO
ExecStop=/bin/fusermount3 -uz /home/ubuntu/workspace
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
`;
await vm.fs.writeTextFile("/tmp/s3-workspace.service", unit);
await vm.exec(
"sudo install -m 644 /tmp/s3-workspace.service /etc/systemd/system/s3-workspace.service && " +
"sudo mkdir -p /var/cache/rclone && sudo chown ubuntu:ubuntu /var/cache/rclone && " +
"sudo systemctl daemon-reload && sudo systemctl enable --now s3-workspace",
);
const mounted = await vm.exec("mountpoint /home/ubuntu/workspace");
if (mounted.statusCode !== 0) throw new Error("mount did not come up");
What each group of flags does:
--cache-dirand--vfs-cache-max-sizeplace the file cache on the sandbox disk and cap it. Size the cap below the free space on the sandbox; the cache evicts least-recently-used files past the cap.--vfs-cache-max-agekeeps unused files for 30 days before eviction.--vfs-write-back 5suploads a file five seconds after its last close. A file written and closed repeatedly uploads once per quiet period. A sandbox stopped inside that window keeps the dirty file in/var/cache/rclone, and rclone uploads it the next time the service starts with the same cache directory.--dir-cache-time 1hcaches directory listings for an hour. S3 has no change notification. Runrclone rc vfs/refresh recursive=trueto see changes another writer made to the bucket before the hour is up.--rcexposes a local control socket for the checks in the last section.Type=notifymarks the service started once the mount is up, so units ordered after it see a working mountpoint.
Verify with a round trip through the bucket:
await vm.fs.writeTextFile("/home/ubuntu/workspace/hello.txt", "hello from freestyle\n");
await vm.exec("sleep 8"); // past --vfs-write-back
console.log((await vm.exec("rclone ls latitude:my-bucket")).stdout); // 21 hello.txt
Keep node_modules and Build Output Local
rclone mounts have no per-path sync rule: everything under the mount is bucket-backed. To keep a directory out of the bucket, mount a directory from the sandbox disk on top of it. The mount hides whatever the bucket holds at that path, and every write under it lands on the sandbox disk.
Write a list of local-only paths, relative to the workspace root, and a helper
that bind-mounts each one. The helper is idempotent, runs as root, and takes
unmount to undo its mounts.
const localOnly = `# One path per line, relative to /home/ubuntu/workspace.
node_modules
app/node_modules
.next
.cache
.turbo
dist
target
.venv
__pycache__
`;
await vm.fs.writeTextFile("/home/ubuntu/.local-only", localOnly);
const helper = `#!/bin/bash
set -euo pipefail
WORKSPACE=/home/ubuntu/workspace
BACKING=/var/lib/local-only
until mountpoint -q "$WORKSPACE"; do sleep 0.2; done
grep -v '^\\s*#' /home/ubuntu/.local-only | grep -v '^\\s*$' | while read -r rel; do
target="$WORKSPACE/$rel"
backing="$BACKING/$rel"
if [ "\${1:-}" = unmount ]; then
mountpoint -q "$target" && umount -l "$target"
continue
fi
mkdir -p "$target" "$backing"
chown ubuntu:ubuntu "$backing"
if ! mountpoint -q "$target"; then
mount --bind "$backing" "$target"
fi
done
`;
await vm.fs.writeTextFile("/tmp/local-only-mounts", helper);
await vm.exec(
"sudo install -m 755 /tmp/local-only-mounts /usr/local/bin/local-only-mounts && " +
"sudo mkdir -p /var/lib/local-only",
);
Run the helper from a second unit bound to the S3 mount. WantedBy starts it
whenever the mount starts, BindsTo stops it when the mount stops, and
After orders it behind the mount’s Type=notify readiness.
const localOnlyUnit = `[Unit]
Description=Local-only paths under the S3 workspace
BindsTo=s3-workspace.service
After=s3-workspace.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/local-only-mounts
ExecStop=/usr/local/bin/local-only-mounts unmount
[Install]
WantedBy=s3-workspace.service
`;
await vm.fs.writeTextFile("/tmp/local-only-mounts.service", localOnlyUnit);
await vm.exec(
"sudo install -m 644 /tmp/local-only-mounts.service /etc/systemd/system/local-only-mounts.service && " +
"sudo systemctl daemon-reload && sudo systemctl enable local-only-mounts && " +
"sudo systemctl restart s3-workspace",
);
const bound = await vm.exec("mountpoint /home/ubuntu/workspace/node_modules");
if (bound.statusCode !== 0) throw new Error("local-only mount did not come up");
Confirm a package install stays local:
await vm.exec("cd /home/ubuntu/workspace && npm init -y >/dev/null && npm install --silent left-pad");
await vm.exec("sleep 8");
console.log((await vm.exec("rclone ls latitude:my-bucket")).stdout);
// package.json and package-lock.json are listed; node_modules is not.
console.log((await vm.exec("ls /var/lib/local-only/node_modules")).stdout); // left-pad
Behavior at the boundary:
rm -rf node_modulesremoves the contents and fails on the directory itself withDevice or resource busy.npm ci,pnpm install, andyarn installclear contents rather than removing the directory and work unchanged. A tool that renamesnode_modulesaside needs the directory removed from.local-onlyand the bind mount unmounted first.- S3 has no empty directories, so the
mkdir -pon the mount writes nothing to the bucket. The helper recreates each mountpoint on every start. - Paths that do not exist yet, such as
app/node_modulesbeforeappis cloned, are created empty by the helper. Rerunsudo local-only-mountsafter adding a path to.local-only. - A path in
.local-onlythat already has contents in the bucket is hidden, not deleted. Unmount it withumount /home/ubuntu/workspace/<path>to see the bucket contents again.
A local-only directory is rebuilt, not restored. On a fresh sandbox against the
same bucket, mount, run the helper, then run npm ci from the lockfile in the
bucket.
Route Excluded Paths in Your Own Daemon
A daemon you write can apply the rule inside the filesystem instead of by
stacking mounts, which survives mv node_modules node_modules.bak and lets a
path change from local-only to synced without unmounting. The Rust
fuser crate handles the FUSE protocol.
Keep one metadata table for the whole tree, in SQLite on the sandbox disk. Each
inode row carries its parent, name, size, mode, mtime, and a local_only flag.
Compute the flag at mkdir and create time: a new entry is local-only when
its own path matches a rule or its parent is local-only. rename recomputes the
flag for the moved subtree and, when a subtree moves from local-only to synced,
enqueues its files for upload.
Store each file whole under a cache directory keyed by inode, one object per
file. open on a file with no cached copy fetches the object with GetObject
and writes it to the cache. read and write go to the cached copy, and
write marks the inode dirty. A background uploader drains dirty inodes after
a write-back delay and skips any inode whose local_only flag is set; for the
rest it uploads the cached file with PutObject. The eviction pass never
evicts a dirty file or a local-only file. The cache is the only copy of those.
fn is_local_only(&self, parent: Inode, name: &str) -> bool {
if self.meta.get(parent).local_only {
return true;
}
let path = self.meta.path_of(parent).join(name);
self.rules.iter().any(|rule| rule.matches(&path))
}
fn create(&mut self, req: &Request, parent: u64, name: &OsStr, mode: u32, umask: u32, flags: i32, reply: ReplyCreate) {
let name = name.to_string_lossy();
let local_only = self.is_local_only(parent, &name);
let ino = self.meta.insert(parent, &name, mode, local_only);
reply.created(&TTL, &self.meta.attr(ino), 0, ino, flags as u32);
}
Write the rules file in the same shape as .local-only above: one glob per
line, matched against the path relative to the mount root, with ** allowed.
Persist the metadata table to the bucket as a single manifest object on a timer
and at unmount, and skip local-only rows when writing it, so a fresh sandbox
reads the manifest and serves stat and readdir for the whole tree before
any file data is fetched.
Flush Before Stopping and Snapshot
A dirty file that has not passed --vfs-write-back exists only in the sandbox
cache. Check the upload queue before stopping or snapshotting the sandbox:
for (;;) {
const stats = await vm.exec("rclone rc vfs/stats");
const { diskCache } = JSON.parse(stats.stdout);
if (diskCache.uploadsInProgress + diskCache.uploadsQueued === 0) break;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
rclone rc vfs/queue lists the queued files with the seconds until each
uploads. Stopping the service runs fusermount3 -uz, which detaches the mount
and exits rclone. Files still queued stay in /var/cache/rclone and upload
when the service next starts with the same cache directory.
A snapshot of the sandbox captures the cache directory,
the local-only backing directories, the rclone config, and the running mount. A
sandbox created from that snapshot resumes with the mount in place and its
node_modules intact. A sandbox created from a plain Ubuntu snapshot against
the same bucket repeats the install and mount steps and rebuilds local-only
paths.
Two sandboxes writing the same file through the same bucket produce
last-upload-wins. Give each sandbox its own prefix, latitude:my-bucket/<vmId>,
when sandboxes must not see each other’s writes.