Use Nix to describe your project’s tools and dependencies, then capture the prepared environment in a Freestyle snapshot. This guide uses the TypeScript SDK to install Nix, prepare a development profile, and run commands on VMs created from that snapshot.
It starts from the existing freestyle/ubuntu base snapshot. The operating
system stays Ubuntu; Nix manages the project’s development environment.
A configuration based on NixOS modules needs a NixOS system and cannot be
applied to this VM with nixos-rebuild.
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 the TypeScript examples server-side with Node 22+. The Ubuntu base has
systemd, Git, curl, certificates, and an ubuntu user with passwordless sudo.
Its bundled Node and Python remain available outside the Nix environment;
use nix develop to select the tools declared by your flake.
Define the Nix environment
Define the installation and project setup as strings in your TypeScript
application. The installation uses upstream Nix 2.35.2 in multi-user
mode and enables flakes. The symlink makes nix available to non-login
execution shells as well as terminals.
import { Freestyle, type Vm } from "freestyle";
const freestyle = new Freestyle();
const installNix = String.raw`#!/bin/bash
set -euo pipefail
curl -fsSL https://releases.nixos.org/nix/nix-2.35.2/install \
-o /tmp/nix-upstream-install.sh
NIX_INSTALLER_YES=1 NIX_INSTALLER_NO_CHANNEL_ADD=1 \
sh /tmp/nix-upstream-install.sh --daemon
rm /tmp/nix-upstream-install.sh
printf '\nextra-experimental-features = nix-command flakes\n' >> /etc/nix/nix.conf
ln -s /nix/var/nix/profiles/default/bin/nix /usr/local/bin/nix
systemctl restart nix-daemon.service
`;
const projectSetup = String.raw`
sudo -u ubuntu -H bash <<'PROJECT'
set -euo pipefail
mkdir -p /home/ubuntu/project
cd /home/ubuntu/project
cat > flake.nix <<'FLAKE'
{
description = "Development environment for a Freestyle VM";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { nixpkgs, ... }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
devShells.x86_64-linux.default = pkgs.mkShell {
packages = [ pkgs.nodejs_22 pkgs.python312 pkgs.hello ];
};
};
}
FLAKE
nix flake lock
nix develop --no-update-lock-file .#default \
--profile /home/ubuntu/nix-dev-profile \
--command bash -e -c 'node --version; python3 --version; hello'
PROJECT
`;
The installer runs as root; the project and its development profile
belong to ubuntu, the VM’s default execution user. String.raw preserves
the shell script’s backslashes when writing it through the filesystem API.
The first preparation resolves the release branch into flake.lock, which
records the exact input revision and hash. Keep that lock file with the
project. Later preparation should use --no-update-lock-file so missing or
changed inputs fail instead of silently producing a different environment.
--profile saves the development environment outside the project directory.
That profile retains its referenced store paths for Nix garbage collection
and lets workers enter the prepared environment directly. See the upstream
installation,
development profile,
and garbage collection
documentation for the underlying behavior.
Prepare and capture the snapshot
Create a VM with Internet access, write the setup script with vm.fs, and
stream its output through a PTY. Preparation can take several
minutes, so use a PTY instead of a buffered vm.exec() request with its
five-minute limit. Disable the preparation VM’s idle timeout because terminal
output alone does not keep a VM active.
async function prepareNixSnapshot(projectSetup: string): Promise<string> {
const { vm: preparation } = await freestyle.vms.create({
snapshotId: "freestyle/ubuntu",
firewall: {
rules: [{ action: "allow", source: {}, destination: { public: true } }],
},
idleTimeoutSeconds: null,
});
try {
await preparation.fs.writeTextFile(
"/tmp/prepare-nix.sh",
`${installNix}\n${projectSetup}`,
);
const setupDone = Promise.withResolvers<number>();
const session = await preparation.linuxUser("root").pty.open({
exec: "bash /tmp/prepare-nix.sh",
onData: (bytes) => process.stdout.write(Buffer.from(bytes)),
onExit: (code) => setupDone.resolve(code),
onError: (error) => setupDone.reject(error),
onClose: () => setupDone.reject(new Error("Setup stream closed before completion")),
});
try {
const code = await setupDone.promise;
if (code !== 0) throw new Error(`Nix setup failed with exit code ${code}`);
} finally {
session.detach();
}
const { snapshotId } = await preparation.snapshot();
return snapshotId;
} finally {
await preparation.delete();
}
}
const snapshotId = await prepareNixSnapshot(projectSetup);
console.log(snapshotId); // Save this ID to create future workers.
The helper captures a snapshot only after setup exits successfully and deletes the preparation VM on success or failure. Keep the returned snapshot ID to pin this exact environment. Future VMs start with the Nix store, its database, the development profile, and your project files already present.
Run commands in the prepared environment
A small runNix() helper takes a VM and a command’s arguments, enters the
saved development profile, and throws if the command fails. Arguments are
quoted before reaching the guest shell. Set HOME and the working directory
explicitly because vm.exec() runs in a non-login shell.
async function runNix(vm: Vm, argv: string[]) {
const args = argv
.map((arg) => "'" + arg.replaceAll("'", "'\\''") + "'")
.join(" ");
const result = await vm.exec(
"export HOME=/home/ubuntu && cd /home/ubuntu/project && " +
"/usr/local/bin/nix develop --offline /home/ubuntu/nix-dev-profile " +
`--command ${args}`,
);
if (result.statusCode !== 0) {
throw new Error(`Nix command failed: ${result.stderr || result.stdout}`);
}
return result;
}
const { vm } = await freestyle.vms.create({
snapshotId,
firewall: { rules: [] },
});
try {
console.log((await runNix(vm, ["node", "--version"])).stdout);
console.log((await runNix(vm, ["python3", "--version"])).stdout);
console.log((await runNix(vm, ["hello"])).stdout); // Hello, world!
} finally {
await vm.delete();
}
This worker has no outbound Internet access, and --offline disables Nix
substituters, so the example checks that the environment is already present.
Your own application’s network needs are separate: allow the destinations it
needs when creating its workers.
Reuse one VM across as many runNix() calls as you need. Each vm.exec()
starts a new shell, so the helper selects the saved profile on every call.
A saved shell hook may also depend on the project directory.
Each worker has its own writable disk and memory. Changes made in a worker do not modify the source snapshot or another worker. Take a new snapshot when you want to retain a changed environment.
Use your existing flake
Replace the demo projectSetup string with your repository setup, then pass
it to the same prepareNixSnapshot() helper. Clone as ubuntu, check out the
commit you intend to capture, and prepare the required shell with that user:
const repositorySetup = String.raw`
sudo -u ubuntu -H bash <<'PROJECT'
set -euo pipefail
git clone https://github.com/your-org/your-project.git /home/ubuntu/project
cd /home/ubuntu/project
git checkout YOUR_COMMIT_SHA
test -f flake.lock
nix develop --no-update-lock-file .#default \
--profile /home/ubuntu/nix-dev-profile \
--command true
PROJECT
`;
const projectSnapshotId = await prepareNixSnapshot(repositorySetup);
Replace the repository URL and commit SHA before running this. For a private
repository, use the private Git guide.
The flake needs a devShells.x86_64-linux output; a macOS-only shell cannot
run on the current Ubuntu base. Replace default with your shell’s name
when needed.
Preparing a dev shell installs its declared dependencies. It does not automatically run your application’s dependency installer, build assets, or populate databases. Add project setup before the script exits and the snapshot is captured. A shell hook can run commands whenever the profile is entered, so check its behavior before expecting workers to operate without network access.
Keep builds in your workflow
Nix checks its configured binary caches for prebuilt dependencies, using
https://cache.nixos.org by default. A cache miss can cause compilation
inside your VM, and nix develop can build a small derivation to record the
shell environment. Preparation uses the VM’s ordinary compute resources.
See Nix cache configuration.
For expensive custom dependencies, prepare matching x86_64-linux outputs
in your own CI or build infrastructure and make them available through your
signed binary cache. Configure its URL and trusted public key in the VM’s
Nix configuration before preparation, or use
nix copy
to transfer a prebuilt dependency closure, including all its referenced
store paths.
Freestyle supplies the VM and retains the snapshot you capture. This workflow does not create a managed Nix build job, publish a binary cache, or rebuild an environment when your flake changes. To update the environment, prepare a new VM and capture a new snapshot. The lock file identifies Nix inputs; the snapshot ID identifies the captured machine, including its mutable files and memory.
Clean up
The examples delete their VMs in finally blocks. Keep snapshotId to
create future workers, or delete the snapshot when it is no longer needed:
await freestyle.vms.snapshots.delete(snapshotId);