Give a sandbox its own directory-like prefix in a private Tigris bucket. Freestyle’s S3 egress transform checks the bucket, object prefix, and operation before signing each request with credentials held at the edge. The VM sends unsigned requests and never receives the real access key or secret.
This guide writes, reads, lists, and deletes a small object. Tigris stores the objects outside the VM, so they remain available after the VM is deleted. Freestyle VM snapshots capture the guest’s disk and memory; they do not snapshot the external bucket.
Create A Private Bucket And Access Key
Create a private bucket in the Tigris Console and an access key permitted to read and write that bucket. Use the narrowest provider-side permissions your workload needs. Bucket creation and account administration happen outside the sandbox.
Tigris’s current S3 configuration
uses https://t3.storage.dev and signing region auto. This example uses
path-style URLs so all operations use one hostname. It does not need a route for
each bucket.t3.storage.dev name.
Set these values on your trusted controller:
export FREESTYLE_API_KEY="your-freestyle-api-key"
export TIGRIS_BUCKET="your-private-bucket"
export TIGRIS_ACCESS_KEY_ID="your-tigris-access-key-id"
export TIGRIS_SECRET_ACCESS_KEY="your-tigris-secret-key"
npm install freestyle@latest
npm install --save-dev tsx
Use Node.js 22 or later for the controller. The guest example uses Python’s
standard library, included in the freestyle/ubuntu base snapshot. No storage
SDK or package installation is needed in the guest.
Grant One Bucket Prefix
Save this as tigris-controller.mts. The VM has no general public-egress grant;
the named TLS rule supplies its path to Freestyle’s edge.
import { Freestyle, type CreateTlsRuleOptions } from "freestyle";
function requiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const bucket = requiredEnv("TIGRIS_BUCKET");
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)) {
throw new Error("TIGRIS_BUCKET must be an existing DNS-compatible bucket name");
}
const accessKeyId = requiredEnv("TIGRIS_ACCESS_KEY_ID");
const secretAccessKey = requiredEnv("TIGRIS_SECRET_ACCESS_KEY");
const freestyle = new Freestyle();
const { vm, vmId } = await freestyle.vms.create({
snapshotId: "freestyle/ubuntu",
slug: "tigris-client",
firewall: { rules: [] },
});
console.log({ vmId });
const prefix = `workspaces/${vmId}/`;
function tigrisRule(
accessKeyId: string,
secretAccessKey: string,
): CreateTlsRuleOptions {
return {
action: "allow",
domain: "t3.storage.dev",
source: { vmId },
destination: { public: true },
transform: [{
s3: {
region: "auto",
credentials: { accessKeyId, secretAccessKey },
scope: {
bucket,
prefix,
operations: [
"PutObject", "GetObject", "HeadObject", "DeleteObject", "ListObjectsV2",
],
},
},
}],
};
}
const route = await freestyle.tls.rules.create(
tigrisRule(accessKeyId, secretAccessKey),
);
console.log({ ruleId: route.id, bucket, prefix });
Keep the trailing / in the prefix: workspaces/vm-1 also matches
workspaces/vm-10. Use the same prefix for another VM only when both should
access the same objects. A read-only workload needs only GetObject,
HeadObject, and ListObjectsV2. Add ListObjects for clients that list with
ListObjects v1.
The access key ID and secret are sealed at rest and returned as "***" on rule
reads. The bucket, prefix, region, and operation list remain visible.
Read And Write Objects From The VM
Continue in the same controller file. This guest script uses unsigned HTTPS, which the edge signs upstream. It uses no Authorization header or real storage credential.
await vm.fs.writeTextFile(
"/home/ubuntu/tigris-request.py",
`import os
import ssl
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
bucket = os.environ["TIGRIS_BUCKET"]
prefix = os.environ["TIGRIS_PREFIX"]
base = "https://t3.storage.dev/" + urllib.parse.quote(bucket, safe="")
context = ssl.create_default_context(cafile="/etc/ssl/certs/ca-certificates.crt")
def request(method, url, body=None):
req = urllib.request.Request(url, data=body, method=method)
if body is not None:
req.add_header("content-type", "text/plain")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
return response.read()
key = prefix + "hello.txt"
url = base + "/" + urllib.parse.quote(key, safe="/")
request("PUT", url, b"Hello from Freestyle!")
assert request("GET", url) == b"Hello from Freestyle!"
print("Object round trip succeeded:", key)
query = urllib.parse.urlencode({"list-type": "2", "prefix": prefix})
listing = ET.fromstring(request("GET", base + "?" + query))
for element in listing.iter():
if element.tag.rsplit("}", 1)[-1] == "Key":
print("Listed:", element.text)
request("DELETE", url)
print("Deleted the example object")
try:
request("GET", base + "/outside-this-workspace.txt")
except urllib.error.HTTPError as error:
if error.code != 403:
raise
print("Out-of-prefix access was rejected with 403")
else:
raise RuntimeError("Expected out-of-prefix access to be rejected")
`,
);
Wait for the hostname mapping and Freestyle CA before running the script. The
root probe does not require an object operation to succeed: even an HTTP 403
proves the TLS route is reachable, so it intentionally omits curl’s --fail.
let ready = false;
for (let attempt = 0; attempt < 15; attempt++) {
const check = await vm.exec({
command: "curl --silent --show-error --output /dev/null --connect-timeout 5 --max-time 10 https://t3.storage.dev/",
timeoutMs: 15_000,
});
if (check.statusCode === 0) {
ready = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
if (!ready) throw new Error("Tigris TLS route did not become ready");
const result = await vm.exec({
command: "python3 /home/ubuntu/tigris-request.py",
env: { TIGRIS_BUCKET: bucket, TIGRIS_PREFIX: prefix },
timeoutMs: 180_000,
});
if (result.statusCode !== 0) throw new Error(result.stderr ?? "Tigris request failed");
console.log(result.stdout);
Run the assembled controller with npx tsx tigris-controller.mts. Expected output
reports a successful object round trip, the listed key, deletion of the example
object, and a 403 for the out-of-prefix request. Python trusts the system CA
bundle that Freestyle updates for the route. Keep certificate verification enabled.
If execution fails after the upload, the example object may remain in Tigris. Delete it through the controller or Console when you no longer need it.
Use An S3 SDK Or Mount The Prefix
For existing boto3 code,
use the same endpoint, region_name="auto", path-style addressing, and
placeholder keys. Point the client at /etc/ssl/certs/ca-certificates.crt and set
request_checksum_calculation="when_required" in its botocore Config to avoid
checksum trailers that the edge rejects. Install packages in a builder VM with
its own temporary package-download access, then snapshot that runtime.
Each list_objects_v2 call must pass Prefix inside the allowed scope; an
unscoped listing is rejected. The edge does not silently narrow a bucket listing.
The scoped rule also excludes bucket creation, bucket listing, ACL changes, and
presigned URLs. Tigris may support those operations directly; this Freestyle
grant intentionally exposes only the named object operations.
Large SDK uploads can use multipart requests. Add CreateMultipartUpload,
UploadPart, ListParts, CompleteMultipartUpload, AbortMultipartUpload, and
ListMultipartUploads only when needed. Multipart listings also require an
explicit scoped prefix. See the S3 transform reference.
To mount the prefix as a directory, adapt the S3 mounting guide: use this Tigris endpoint and region, placeholder credentials, and the same prefix. FUSE caching and uploads have their own lifecycle; external objects remain separate from Freestyle VM snapshots.
Rotate Keys And Clean Up
Create replacement Tigris credentials and update the complete rule from the controller:
await freestyle.tls.rules.update(
route.id,
tigrisRule(
requiredEnv("TIGRIS_ACCESS_KEY_ID"),
requiredEnv("TIGRIS_SECRET_ACCESS_KEY"),
),
);
After the update reaches the guest, verify a fresh object operation before revoking the old key in Tigris. Rule reads cannot reconstruct redacted credentials. Tigris’s S3 interface uses long-lived key pairs; do not assume automatic STS refresh.
Delete the route and VM when the workload is finished, including after a failed example:
await freestyle.tls.rules.delete(route.id);
await vm.delete();
Deleting the VM does not delete its bucket objects. Removing the rule revokes this route; revoke the Tigris access key separately when it should stop working everywhere.