Freestyle sandboxes are microVMs with a full Linux kernel, so the Docker Engine runs inside one natively — overlayfs storage, cgroup v2, and bridge networking all work, no vfs fallback. Docker is already installed in all Ubuntu base snapshots, including every size from freestyle/ubuntu-sm through freestyle/ubuntu-3xl. The Engine starts automatically, and the compose and buildx plugins are included, so you can run containers and docker compose stacks immediately.
Install the SDK
pnpm add freestyle@latestbun add freestyle@latestnpm install freestyle@latestyarn add freestyle@latest Set your API key before calling the API:
export FREESTYLE_API_KEY="your-api-key"
Run a Container
Boot a VM from an Ubuntu snapshot. Docker is already running, so vm.exec() can run containers immediately — no install or custom snapshot needed.
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
const { vm, vmId } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "docker-server",
snapshotId: "freestyle/ubuntu",
idleTimeoutSeconds: null,
});
const hello = await vm.exec("docker run --rm hello-world");
console.log(hello.stdout?.includes("Hello from Docker")); // true
// Any image works — this pulls alpine and runs a command in it.
const ok = await vm.exec("docker run --rm alpine echo 'container ok'");
console.log(ok.stdout?.trim()); // container ok
Access Container Files with the Filesystem API
Use the Freestyle filesystem API to read and write a folder in the VM, then bind mount that folder into your container. In this example, /home/ubuntu/container-files in the VM appears as /workspace inside the container. Pass the VM path to vm.fs; container processes use the mounted path.
// Create the source directory before starting the container.
await vm.fs.mkdir("/home/ubuntu/container-files");
await vm.fs.writeTextFile("/home/ubuntu/container-files/input.txt", "hello from freestyle\n");
const fileWorker = await vm.exec(
"docker run -d --name file-worker " +
"--mount type=bind,source=/home/ubuntu/container-files,target=/workspace " +
"alpine tail -f /dev/null",
);
if (fileWorker.statusCode !== 0) {
throw new Error(fileWorker.stderr || fileWorker.stdout || "Container failed to start");
}
// Update a file while the container is running.
await vm.fs.writeTextFile("/home/ubuntu/container-files/input.txt", "updated from freestyle\n");
// The container reads the updated input and writes output into the same folder.
const processed = await vm.exec(
"docker exec file-worker sh -c \"tr '[:lower:]' '[:upper:]' < /workspace/input.txt > /workspace/output.txt\"",
);
if (processed.statusCode !== 0) {
throw new Error(processed.stderr || processed.stdout || "File processing failed");
}
const output = await vm.fs.readTextFile("/home/ubuntu/container-files/output.txt");
console.log(output); // UPDATED FROM FREESTYLE
Both paths refer to the same files, so you can supply inputs and retrieve results without docker cp or rebuilding the image. Mount the whole directory: filesystem API writes replace files atomically, and the container sees the new contents when it next opens the file. Applications that cache file contents may need a reload.
The folder remains in the VM after the container is removed and is included when you snapshot the VM. For containers running as a non-root user, give that user’s UID/GID the required permissions on the shared folder.
Build an Image and Stream the Build Logs
Build a Docker image inside the same VM, then start a container from it. This example copies a small worker into an Alpine image; the worker writes a numbered log line every five seconds. Run the SDK code server-side with Node 22+.
await vm.exec("mkdir -p /root/docker-worker");
await vm.fs.writeTextFile(
"/root/docker-worker/Dockerfile",
`FROM alpine:3.22
WORKDIR /app
COPY worker.sh .
CMD ["sh", "/app/worker.sh"]
`,
);
await vm.fs.writeTextFile(
"/root/docker-worker/worker.sh",
`#!/bin/sh
count=0
while true; do
count=$((count + 1))
echo "worker tick $count"
sleep 5
done
`,
);
Use a PTY to stream docker build output live. --progress=plain prints each build step as text, and onData forwards the output to your terminal. Waiting for onExit ensures the image finishes building successfully before you start the container:
const buildDone = Promise.withResolvers<number>();
const buildSession = await vm.pty.open({
exec: "docker build --progress=plain -t my-worker:latest /root/docker-worker",
onData: (bytes) => process.stdout.write(Buffer.from(bytes)),
onExit: (code) => buildDone.resolve(code),
onError: (error) => buildDone.reject(error),
onClose: () => buildDone.reject(new Error("Build log stream closed before completion")),
});
try {
const code = await buildDone.promise;
if (code !== 0) throw new Error(`Docker build failed with exit code ${code}`);
} finally {
buildSession.detach();
}
const start = await vm.exec("docker run -d --name my-worker my-worker:latest");
if (start.statusCode !== 0) {
throw new Error(start.stderr || start.stdout || "Container failed to start");
}
console.log((await vm.exec("docker logs my-worker")).stdout);
Trust Custom Certificates Inside Containers
Installing a root CA in the VM does not install it in Docker images or
containers. A request can succeed with curl in the VM and fail inside a
container with unable to get local issuer certificate. Python Requests adds
another layer: a pip-installed Requests normally uses certifi’s bundle, so
even updating the container’s system trust store may leave Requests failing.
For an existing application image, mount the VM’s certificate directory read-only and point the application at its bundle. Run this inside the Ubuntu VM, after installing your CA or creating the Freestyle TLS rule:
docker run --rm \
--mount type=bind,source=/etc/ssl/certs,target=/etc/freestyle-certs,readonly \
--env REQUESTS_CA_BUNDLE=/etc/freestyle-certs/ca-certificates.crt \
--env SSL_CERT_FILE=/etc/freestyle-certs/ca-certificates.crt \
--env NODE_EXTRA_CA_CERTS=/etc/freestyle-certs/ca-certificates.crt \
my-worker:latest
Replace my-worker:latest with your application image, with its dependencies
already installed. These variables belong to the container process;
exporting them only in the VM’s shell does not pass them to docker run.
REQUESTS_CA_BUNDLE configures Requests; SSL_CERT_FILE configures HTTPX and
curl builds that use OpenSSL; NODE_EXTRA_CA_CERTS adds
trust for Node. See client certificate trust
for runtime-specific behavior. Other runtimes may need their own configuration.
The bundle contains the VM’s public roots and installed custom CAs. Mount the directory rather than just the bundle file so atomic bundle replacements remain visible. Restart application processes after trust changes. A mount does not install certificates into the image or configure hostname resolution; Freestyle TLS routes also need the network setup below.
If you prefer to install a CA in the image, add this to a Debian/Ubuntu-based
Dockerfile whose base already includes ca-certificates:
COPY root-ca.crt /usr/local/share/ca-certificates/root-ca.crt
RUN update-ca-certificates
ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
Supply the public CA certificate as root-ca.crt, in PEM format; never copy its
private key. For Freestyle egress, the VM’s installed certificate is
/usr/local/share/ca-certificates/freestyle-tls.crt. Rebuild the image and recreate
containers when a copied CA changes. Use your distribution’s equivalent commands
for other base images. Docker’s CA certificate reference
covers image and running-container installation.
Runtime mounts do not apply to docker build steps. If a RUN pip install or
other build step needs the CA, make the certificate and the appropriate runtime
setting available before that step. The builder must separately trust any
private registry used by FROM.
Use Freestyle TLS Routes From Docker
Freestyle configures a granted hostname and its CA in the VM. Docker does not
inherit the VM’s custom /etc/hosts entries.
Without that mapping, an alias such as inference.local may not resolve; a public
provider hostname may resolve directly to the provider and miss the transform.
Use a Docker network with IPv6 enabled and pass the granted hostname’s installed
IPv6 edge address into the container. This uses the IPv6 path available to the VM;
an IPv4-only bridge is insufficient when the edge is reachable only over IPv6.
First verify an HTTPS request from the VM. For the direct api.openai.com
rule in the OpenAI guide:
# Run after the TLS rule has installed api.openai.com in the VM's /etc/hosts.
export FREESTYLE_EDGE_IP="$(awk '$2 == "api.openai.com" && $1 ~ /:/ {print $1; exit}' /etc/hosts)"
test -n "$FREESTYLE_EDGE_IP" || { echo "Wait for the TLS hostname mapping" >&2; exit 1; }
docker network create --ipv6 model-egress
docker run --rm --network model-egress \
--add-host "api.openai.com=$FREESTYLE_EDGE_IP" \
--mount type=bind,source=/etc/ssl/certs,target=/etc/freestyle-certs,readonly \
--env REQUESTS_CA_BUNDLE=/etc/freestyle-certs/ca-certificates.crt \
--env SSL_CERT_FILE=/etc/freestyle-certs/ca-certificates.crt \
--env NODE_EXTRA_CA_CERTS=/etc/freestyle-certs/ca-certificates.crt \
my-worker:latest
Create the network once and reuse it. For another provider, replace
api.openai.com in both the lookup and --add-host with the rule’s domain.
Read the address from the VM; do not hardcode an edge IP or use Docker’s
host-gateway, which addresses the VM rather than Freestyle’s edge. Retain
the provider guide’s exact SDK base URL and request path.
For Compose, set FREESTYLE_EDGE_IP as above and use:
services:
worker:
image: my-worker:latest
extra_hosts:
- "api.openai.com=${FREESTYLE_EDGE_IP:?Set FREESTYLE_EDGE_IP from the VM hosts file}"
volumes:
- /etc/ssl/certs:/etc/freestyle-certs:ro
environment:
REQUESTS_CA_BUNDLE: /etc/freestyle-certs/ca-certificates.crt
SSL_CERT_FILE: /etc/freestyle-certs/ca-certificates.crt
NODE_EXTRA_CA_CERTS: /etc/freestyle-certs/ca-certificates.crt
networks:
default:
enable_ipv6: true
For a host alias such as inference.local, first verify that the VM itself
can reach that route. With restricted egress, prefer the direct provider’s
hostname and destination: { public: true }: host-alias rules currently omit
the automatic edge firewall grant and can time out independently of CA trust.
See the migration guide’s alias limitation.
Keep the VM’s firewall and TLS grants scoped to the required destinations.
Re-read the mapping and recreate containers after route changes. A DNS error
or connection timeout needs a routing check; CERTIFICATE_VERIFY_FAILED needs
a trust-store check. An HTTP response means TLS completed, though an HTTP error
may still indicate an authentication or application problem.
Trust A Private Registry For Pulls And Pushes
An x509: certificate signed by unknown authority error from docker pull or
docker push comes from the Docker daemon’s registry connection. Container
mounts and REQUESTS_CA_BUNDLE do not configure that connection.
On Freestyle’s native Linux Docker Engine, install the registry’s public CA under its exact registry name:
sudo mkdir -p /etc/docker/certs.d/registry.example.com:5443
sudo install -m 0644 root-ca.crt /etc/docker/certs.d/registry.example.com:5443/ca.crt
docker pull registry.example.com:5443/team/image:tag
Replace the hostname, port, and image with yours. Omit :5443 from the directory
name when the image reference has no explicit port. Use .crt for CA roots;
Docker interprets .cert as a client certificate. See Docker’s
registry certificate configuration.
A running daemon can retain its previously loaded system roots after the VM’s
update-ca-certificates succeeds. In our Ubuntu reproduction, per-registry
ca.crt fixed pull and push without a Docker restart. If an error names an
authentication or redirect host, check that host’s certificate chain too.
For a separate BuildKit daemon or docker-container builder, configure its
registry CA
too. Keep certificate verification enabled; neither insecure registries nor
verify=False are required for these fixes.
Snapshot the Running Container
Once the container is running, snapshot the VM. A Freestyle snapshot captures the VM’s memory and disk, including the built image, Docker daemon, and running container. A new VM created from it resumes the worker with its in-memory counter intact.
const running = await vm.exec("docker inspect --format '{{.State.Running}}' my-worker");
if (running.statusCode !== 0 || running.stdout?.trim() !== "true") {
throw new Error("The worker must be running before taking the snapshot");
}
const { snapshotId } = await vm.snapshot({ slug: "docker-worker-running" });
const { vm: restored } = await freestyle.vms.create({
snapshotId,
slug: "docker-worker-restored",
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
idleTimeoutSeconds: null,
});
// The container is already running in the restored VM.
console.log((await restored.exec("docker ps --filter name=my-worker")).stdout);
console.log((await restored.exec("docker logs --tail 10 my-worker")).stdout);
You do not need to rebuild the image or run docker start in the restored VM. This snapshots the entire VM; it is separate from docker commit, which creates an image from a container’s filesystem changes.
Run a Docker Compose Stack
Write a compose.yaml into the VM and bring it up. The Compose plugin came with the engine, so docker compose is available with no extra install. Published ports answer on the VM’s loopback.
await vm.exec("mkdir -p /root/app");
await vm.fs.writeTextFile(
"/root/app/compose.yaml",
`services:
web:
image: nginx:alpine
ports:
- "8080:80"
worker:
image: alpine
command: sh -c "while true; do echo 'worker tick'; sleep 5; done"
`,
);
await vm.exec("cd /root/app && docker compose up -d");
console.log((await vm.exec("cd /root/app && docker compose ps")).stdout);
// The web service's published port answers.
const res = await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:8080");
console.log(res.stdout); // 200
Stream the Container Logs
vm.exec() buffers output until the command finishes. To stream the worker’s logs as they arrive, open a PTY on the restored VM and follow the named container with docker logs --follow. Run this SDK code server-side with Node 22+; onData receives the terminal output over a WebSocket:
const session = await restored.pty.open({
cols: 120,
rows: 30,
onData: (bytes) => process.stdout.write(Buffer.from(bytes)),
});
session.write("docker logs --follow --tail 20 my-worker\n");
// When finished watching, call session.detach().
// Detaching closes the stream; the container keeps running.
Use vm.pty.open() instead to follow the worker in the original VM. For the Compose stack above, open a PTY on vm and follow all its services:
const composeSession = await vm.pty.open({
onData: (bytes) => process.stdout.write(Buffer.from(bytes)),
});
composeSession.write("cd /root/app && docker compose logs --follow --tail 20\n");
Expose a Container on a Domain
A published container port behaves like any other service on the VM. The web service above publishes port 8080, so route a domain there — any unused subdomain of style.dev needs no verification:
await freestyle.tls.rules.create({
action: "allow",
domain: "my-app.style.dev", // any unused style.dev subdomain
source: { public: true },
destination: { vmId, port: 8080 },
});
A domain of your own has to be verified first; HTTPS is provisioned automatically either way.