A 101 Guide: Running an Eve Agent in a Firecracker microVM on AWS
Vercel's eve ships with a sandbox story out of the box: run vercel deploy and your agent's code execution is isolated by Vercel Sandbox in production. That is the right default for most teams. But if you want the agent running on infrastructure you own — no third-party sandbox in the loop, full control over the VM boundary, billed at raw EC2 rates — you can host that isolation yourself with Firecracker, the microVM technology AWS built to power Lambda and Fargate.
This is a 101 guide: enough to get one Firecracker microVM running an eve agent on AWS, provisioned with CDK in TypeScript. It is not a production hardening guide — there is no autoscaling, no snapshotting, no multi-tenant jailer configuration. It is the shortest path from cdk deploy to a working microVM you can talk to.
Why Firecracker instead of a container
A Docker container shares the host kernel with every other container on the box. Firecracker instead boots a real, minimal virtual machine — its own kernel, in around 125ms, with a memory footprint under 5MB per microVM. That gives you a hardware-enforced boundary between the agent's code execution and everything else running on the host, which is the same property Vercel Sandbox is providing for you managed. Running it yourself means you own that boundary directly, at the cost of managing it yourself.
The catch: Firecracker needs direct access to /dev/kvm, which is only exposed on bare-metal EC2 instances (or Nitro instances with nested virtualization enabled). You cannot run it on an ordinary t3.micro. That single constraint drives most of the infrastructure decisions below.
What we're building
VPC (public subnet)
└── Security Group — SSH (restricted to your IP) + Session Manager
└── EC2 bare-metal instance (m5.metal, Amazon Linux 2023)
├── IAM role — SSM managed instance core (no SSH key needed)
├── Firecracker binary + jailer (installed via user data)
└── One microVM (guest kernel + rootfs)
└── Node.js + the eve agent, running inside the guest
The EC2 instance is the host. Firecracker runs as a process on the host and boots the guest microVM, and the eve agent runs inside that guest — not on the host directly.
Prerequisites
- An AWS account with permissions to create EC2, VPC, and IAM resources
- Node.js 18+ and the AWS CDK CLI:
npm install -g aws-cdk aws configurealready run locally with valid credentials- Familiarity with the basics of
eve— see the quick example on building an agent with eve if you haven't scaffolded one before
Bare-metal instances bill for a minimum multi-hour term in some regions and are not covered by most free-tier credits — check current pricing for m5.metal before leaving this running. Destroy the stack when you're done (cdk destroy, covered at the end).
Step 1: Scaffold the CDK project
mkdir eve-firecracker-infra && cd eve-firecracker-infra
cdk init app --language typescript
npm install aws-cdk-lib constructs
This gives you the standard CDK v2 layout: bin/, lib/, cdk.json. We'll replace the generated stack with the one below.
Step 2: The network and security group
Create lib/firecracker-host-stack.ts. Start with a minimal VPC and a security group that allows SSH from your own IP only, plus outbound access for the host to download the Firecracker binary and guest images:
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as iam from "aws-cdk-lib/aws-iam";
export interface FirecrackerHostStackProps extends cdk.StackProps {
myIp: string; // e.g. "203.0.113.10/32"
}
export class FirecrackerHostStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: FirecrackerHostStackProps) {
super(scope, id, props);
const vpc = new ec2.Vpc(this, "FirecrackerVpc", {
maxAzs: 1,
natGateways: 0,
subnetConfiguration: [
{ name: "public", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
],
});
const sg = new ec2.SecurityGroup(this, "FirecrackerHostSg", {
vpc,
description: "Firecracker host — SSH from operator IP only",
allowAllOutbound: true,
});
sg.addIngressRule(
ec2.Peer.ipv4(props.myIp),
ec2.Port.tcp(22),
"SSH from operator"
);
// ...instance role and instance defined in the next step
}
}
A single-AZ VPC with no NAT gateway is enough for a single dev host and keeps the bill down — the instance sits in a public subnet with a security group as the only gate.
Step 3: The bare-metal instance and its IAM role
Add the instance role and the instance itself inside the same stack. We use AWS Systems Manager Session Manager instead of an SSH key pair, so there is no key file to lose track of:
const role = new iam.Role(this, "FirecrackerHostRole", {
assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"AmazonSSMManagedInstanceCore"
),
],
});
const userData = ec2.UserData.forLinux();
userData.addCommands(
"set -euo pipefail",
"dnf install -y docker",
// KVM access for the ec2-user, needed to run Firecracker without sudo
"usermod -aG kvm ec2-user",
"ARCH=$(uname -m)",
"release_url=$(curl -s https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest | grep browser_download_url | grep ${ARCH} | cut -d '\"' -f 4)",
"curl -L ${release_url} -o firecracker.tgz",
"tar -xzf firecracker.tgz",
"mv release-*/firecracker-*-${ARCH} /usr/local/bin/firecracker",
"chmod +x /usr/local/bin/firecracker"
);
const instance = new ec2.Instance(this, "FirecrackerHost", {
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
instanceType: new ec2.InstanceType("m5.metal"),
machineImage: ec2.MachineImage.latestAmazonLinux2023(),
securityGroup: sg,
role,
userData,
blockDevices: [
{
deviceName: "/dev/xvda",
volume: ec2.BlockDeviceVolume.ebs(40, {
volumeType: ec2.EbsDeviceVolumeType.GP3,
}),
},
],
});
new cdk.CfnOutput(this, "InstanceId", { value: instance.instanceId });
m5.metal is the smallest bare-metal size in the m5 family with guaranteed /dev/kvm access — that's the property Firecracker needs and the reason we didn't reach for a regular m5.large. The user data installs the Firecracker binary automatically on first boot, so by the time you connect, it's already on the box.
Wire the stack up in bin/eve-firecracker-infra.ts:
import * as cdk from "aws-cdk-lib";
import { FirecrackerHostStack } from "../lib/firecracker-host-stack";
const app = new cdk.App();
new FirecrackerHostStack(app, "EveFirecrackerHost", {
myIp: process.env.MY_IP ?? "0.0.0.0/32",
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
Step 4: Deploy
export MY_IP="$(curl -s ifconfig.me)/32"
cdk bootstrap # first time only, per account/region
cdk deploy
cdk deploy will prompt to confirm the IAM role changes — approve it, and wait for the instance to come up. Grab the instance ID from the stack output.
Step 5: Connect and confirm Firecracker is ready
Connect through Session Manager rather than SSH — no key pair required:
aws ssm start-session --target <instance-id>
Once connected, confirm the binary installed correctly and KVM is visible:
firecracker --version
ls -l /dev/kvm
If /dev/kvm isn't there, double check the instance type is genuinely bare metal — this is the most common thing to get wrong in this whole guide.
Step 6: Fetch a guest kernel and rootfs
Firecracker boots a guest from an uncompressed Linux kernel and an ext4 rootfs image. For a first microVM, pull the same minimal images the official Firecracker quickstart uses:
mkdir -p ~/vm && cd ~/vm
ARCH=$(uname -m)
curl -fsSL -o vmlinux.bin \
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/${ARCH}/kernels/vmlinux.bin"
curl -fsSL -o rootfs.ext4 \
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/${ARCH}/rootfs/bionic.rootfs.ext4"
This rootfs is a stock Ubuntu image with no eve or Node.js in it — that's fine, we install both after the guest is booted, in Step 8.
Step 7: Launch the microVM
Firecracker is configured over a Unix socket API. Start the process and hand it a minimal config in one step:
sudo rm -f /tmp/firecracker.socket
cat > vm-config.json <<'EOF'
{
"boot-source": {
"kernel_image_path": "/home/ec2-user/vm/vmlinux.bin",
"boot_args": "console=ttyS0 reboot=k panic=1 pci=off"
},
"drives": [
{
"drive_id": "rootfs",
"path_on_host": "/home/ec2-user/vm/rootfs.ext4",
"is_root_device": true,
"is_read_only": false
}
],
"machine-config": {
"vcpu_count": 2,
"mem_size_mib": 1024
},
"network-interfaces": [
{
"iface_id": "eth0",
"guest_mac": "AA:FC:00:00:00:01",
"host_dev_name": "veth0"
}
]
}
EOF
sudo firecracker --api-sock /tmp/firecracker.socket --config-file vm-config.json
The network-interfaces block assumes a veth0 tap device wired to the host — setting up that tap device and NAT rule is a few extra ip commands beyond the scope of this 101 guide; the Firecracker networking docs walk through it in full. For a first test without networking, drop the network-interfaces block entirely and reach the guest through the serial console Firecracker attaches to your terminal instead.
Step 8: Install and run eve inside the guest
Once the guest boots, you're at a console prompt inside the microVM. From here it's the same eve setup as running locally, just one layer of virtualization down:
# inside the guest
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
npx eve@latest init my-agent
cd my-agent
npx eve dev --host 0.0.0.0
With the tap device and NAT from Step 7 in place, the agent's dev server is reachable from the host at the guest's IP. Without it, the serial console is enough to confirm the agent boots and responds — which, for a first pass at the infrastructure, is the point of this guide.
Step 9: Make It Observable — Metrics, Alerts, and Cost
A host you SSM into by hand is fine for a first boot, but the moment a platform team is meant to own this box, "is it up?" and "what is it costing?" need to be answerable without logging in. There are three things worth watching, and AWS gives you a native path to each: the host (is it healthy, how loaded, how many microVMs are running), the agent (is it serving, how slow, is it erroring), and the bill (bare metal is not cheap, and it should never surprise you). We wire up all three below — mostly more CDK on the stack from Steps 2–3, plus a little OpenTelemetry for the agent itself.
Host metrics with the CloudWatch agent
EC2 publishes CPU, network, and status checks to CloudWatch for free, but not memory, disk, or process counts — and on a Firecracker host, "how much memory is left" and "how many microVMs are running" are the two numbers you actually care about. The CloudWatch agent fills that gap. Grant the host role permission to publish, then install and configure the agent in user data (extending the role and userData from Step 3):
// Let the host publish custom metrics and logs.
role.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchAgentServerPolicy")
);
userData.addCommands(
"dnf install -y amazon-cloudwatch-agent",
// procstat counts running `firecracker` processes = live microVMs on this host.
"cat > /opt/aws/amazon-cloudwatch-agent/etc/config.json <<'EOF'",
JSON.stringify(
{
metrics: {
namespace: "EveFleet/Host",
append_dimensions: { InstanceId: "${aws:InstanceId}" },
metrics_collected: {
mem: { measurement: ["mem_used_percent"] },
disk: { measurement: ["used_percent"], resources: ["/"] },
procstat: [{ pattern: "firecracker", measurement: ["pid_count"] }],
},
},
},
null,
2
),
"EOF",
"/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl " +
"-a fetch-config -m ec2 -s -c file:/opt/aws/amazon-cloudwatch-agent/etc/config.json"
);
You now have mem_used_percent, disk usage, and procstat_lookup_pid_count (your live-microVM gauge) flowing into the EveFleet/Host namespace, each tagged with the instance ID.
Alerts with CloudWatch Alarms and SNS
Metrics nobody looks at are not observability — you want to be told when something is wrong. An SNS topic with an email subscription is the simplest fan-out; every alarm points its action at that topic. Add to the stack:
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import * as cwActions from "aws-cdk-lib/aws-cloudwatch-actions";
import * as sns from "aws-cdk-lib/aws-sns";
import * as subs from "aws-cdk-lib/aws-sns-subscriptions";
const alerts = new sns.Topic(this, "FleetAlerts", {
displayName: "eve-firecracker host alerts",
});
alerts.addSubscription(new subs.EmailSubscription("platform@example.com"));
const dims = { InstanceId: instance.instanceId };
const action = new cwActions.SnsAction(alerts);
// 1. The host failed its EC2 status checks — hardware or reachability.
new cloudwatch.Alarm(this, "StatusCheckAlarm", {
metric: new cloudwatch.Metric({
namespace: "AWS/EC2",
metricName: "StatusCheckFailed",
dimensionsMap: dims,
statistic: "Maximum",
period: cdk.Duration.minutes(1),
}),
threshold: 1,
evaluationPeriods: 2,
comparisonOperator:
cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
}).addAlarmAction(action);
// 2. Sustained high CPU — the host is packed; time to think about a second one.
new cloudwatch.Alarm(this, "CpuHighAlarm", {
metric: new cloudwatch.Metric({
namespace: "AWS/EC2",
metricName: "CPUUtilization",
dimensionsMap: dims,
statistic: "Average",
period: cdk.Duration.minutes(5),
}),
threshold: 85,
evaluationPeriods: 3,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
}).addAlarmAction(action);
// 3. Memory pressure — from the CloudWatch agent metric we just configured.
new cloudwatch.Alarm(this, "MemHighAlarm", {
metric: new cloudwatch.Metric({
namespace: "EveFleet/Host",
metricName: "mem_used_percent",
dimensionsMap: dims,
statistic: "Average",
period: cdk.Duration.minutes(5),
}),
threshold: 90,
evaluationPeriods: 3,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
}).addAlarmAction(action);
The status-check and memory alarms are the two that page you; the CPU one is more of a capacity signal. The live-microVM gauge (procstat_lookup_pid_count) is better as a dashboard graph than an alarm — zero is a real state when nothing is scheduled — but if this host is supposed to always run at least one agent, an alarm on it dropping to zero is a good "did the fleet just go dark?" tripwire.
Agent traces with OpenTelemetry
The host tells you the box is healthy; it tells you nothing about whether the agent is answering, and how slowly. That is application telemetry, and the clean way to get it off a self-hosted agent is OpenTelemetry. Because eve runs each conversation as a durable, checkpointed workflow, its steps map naturally onto spans — an OTel trace of one agent turn shows you each tool call and model round-trip as a timed child span.
Run the AWS Distro for OpenTelemetry (ADOT) Collector on the host as the local sink, and point the agent at it. The collector receives OTLP and fans it out to CloudWatch (metrics, via the EMF exporter) and X-Ray (traces):
# /opt/adot/config.yaml on the host
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
batch: {}
exporters:
awsemf:
namespace: EveFleet/Agents
region: us-east-1
awsxray:
region: us-east-1
service:
pipelines:
metrics: { receivers: [otlp], processors: [batch], exporters: [awsemf] }
traces: { receivers: [otlp], processors: [batch], exporters: [awsxray] }
Give the host role AWSXRayDaemonWriteAccess (the CloudWatch policy from earlier already covers EMF), then start the agent inside the guest with OTel auto-instrumentation pointed at the collector:
# inside the guest, instead of the plain `npx eve dev` from Step 8
npm install @opentelemetry/auto-instrumentations-node
OTEL_EXPORTER_OTLP_ENDPOINT="http://<host-tap-ip>:4317" \
OTEL_SERVICE_NAME="my-agent" \
node --require @opentelemetry/auto-instrumentations-node/register \
node_modules/.bin/eve dev --host 0.0.0.0
Now each agent turn shows up as a trace in X-Ray and its latency/error metrics in CloudWatch under EveFleet/Agents — the same OTLP the agent already speaks, just terminated on infrastructure you own. (This is exactly the pattern that scales to a fleet: one collector per host, every agent on that host exporting to localhost.)
FinOps: never be surprised by the bill
Bare metal is the whole cost story here — an m5.metal runs about ten times an m5.large, and it bills whether one microVM or forty are on it. Two AWS-native controls keep that honest. First, tag everything so the spend is attributable in Cost Explorer:
cdk.Tags.of(this).add("project", "eve-firecracker");
cdk.Tags.of(this).add("env", "lab");
Then activate project as a cost allocation tag in the Billing console (a one-time click; data backfills over ~24h), and you can slice Cost Explorer by project to see exactly what this platform costs versus everything else in the account.
Second, put a hard tripwire on the spend with AWS Budgets — an alert at 80% of actual and a forecast alert at 100%, so you hear about an overrun before the month closes:
import * as budgets from "aws-cdk-lib/aws-budgets";
new budgets.CfnBudget(this, "FleetBudget", {
budget: {
budgetName: "eve-firecracker-monthly",
budgetType: "COST",
timeUnit: "MONTHLY",
budgetLimit: { amount: 300, unit: "USD" },
costFilters: { TagKeyValue: ["user:project$eve-firecracker"] },
},
notificationsWithSubscribers: [
{
notification: {
notificationType: "ACTUAL",
comparisonOperator: "GREATER_THAN",
threshold: 80,
},
subscribers: [
{ subscriptionType: "EMAIL", address: "platform@example.com" },
],
},
{
notification: {
notificationType: "FORECASTED",
comparisonOperator: "GREATER_THAN",
threshold: 100,
},
subscribers: [
{ subscriptionType: "EMAIL", address: "platform@example.com" },
],
},
],
});
Between the tag-sliced Cost Explorer view and the budget alerts, the platform team gets both the "what did it cost" report and the "we're about to overspend" page — the two questions finance actually asks — without anyone watching a dashboard.
Cleaning up
Firecracker VMs stop the moment the process exits — Ctrl-] at the console, or kill the firecracker process, tears down the guest. To remove the AWS side entirely and stop paying for the bare-metal host:
cdk destroy
Where this goes next
This guide gets you one microVM running one agent on one host you SSM into by hand — a starting point, not an architecture. The gap between here and something you'd actually run agent workloads on: Firecracker's jailer process to properly sandbox each microVM (chroot, cgroups, seccomp — skipped here for brevity), a launcher that provisions one microVM per incoming agent session instead of one long-lived guest, and snapshotting so new microVMs boot from a warm snapshot instead of a cold kernel each time. AWS's own Firecracker documentation is the right next stop for all three. If you want the platform version — a pool of these hosts with a control plane that places agents onto them, and the observability from Step 9 rolled up across the whole fleet — that is exactly what our Firecracker fleet series builds.
If what you actually need is that multi-tenant isolation without building the launcher and snapshot machinery yourself, that's precisely the gap Vercel Sandbox is built to fill — worth reconsidering vercel deploy once you've seen firsthand what running the isolation layer yourself involves.