Freestyle Docs

Freestyle / Guides

How to Run Docker in a Sandbox

Run containers and Docker Compose stacks with Docker already installed in every Ubuntu snapshot.

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@latest
bun add freestyle@latest
npm install freestyle@latest
yarn 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.

esc