> Blog Post

Running a Fleet of Firecracker microVMs for eve.dev Agents, Part 1: Architecture & the Network Foundation

In the single-VM 101 guide we booted one Firecracker microVM on one bare-metal EC2 host and ran one eve agent inside it. That is the right way to understand the moving parts, and the wrong way to run more than one agent. The moment a second developer wants to ship a second agent, "SSH into the box and launch a microVM by hand" stops being a plan.

This series turns that single host into a fleet: a pool of bare-metal hosts, each running many Firecracker microVMs, with a control plane that a developer can hand a new agent to and have it placed, booted, and routed automatically. The goal by the end is a single command — deploy my-agent — that takes an eve agent directory on a laptop and gives back a URL you can talk to, with the platform deciding which host has room and wiring up the network for you. Every piece of it is AWS CDK in TypeScript, so the whole fleet is reviewable, versioned infrastructure you can cdk deploy and tear down again.

This first part does two things. It lays out the architecture for the whole series so you know where every later part fits. Then it builds the layer that genuinely has to come first: the network the fleet lives in. By the end you will have a VPC, public and private subnets, and the security groups that separate the control plane, the hosts, and the running agents — all defined in TypeScript.

What We Are Building

The platform has three moving parts, and it is worth naming them before any code, because every later part slots into one of them.

  • The host fleet. A group of bare-metal EC2 instances. Each one runs the Firecracker binary and a small host agent daemon that boots, tracks, and tears down microVMs on that box. Hosts are cattle — the fleet scales up and down, and any host can run any agent.
  • The control plane. A serverless brain — API Gateway in front of Lambda, with DynamoDB as the registry — that knows which hosts exist, how much capacity each has, and where every agent is placed. A deploy request lands here; the control plane picks a host and records the placement.
  • The routing layer. An Application Load Balancer and a per-agent addressing scheme that gets an inbound request to the specific microVM running that agent, wherever the control plane happened to place it.

Here is the destination — the full path a request takes, from a developer's deploy through to a live agent answering traffic:

   developer                                    ┌──────────────────────────┐
   $ deploy my-agent  ───── build rootfs ─────► │  S3 artifact bucket      │
        │                   (Part 3)            │  agent images + kernel   │
        │  POST /agents                         └────────────┬─────────────┘
        ▼                                                     │ host pulls image
  ┌─────────────────────┐     place agent      ┌──────────────▼─────────────┐
  │  control plane       │ ───────────────────►│      host fleet (ASG)       │
  │  API GW + Lambda     │                     │   bare-metal *.metal hosts  │
  │  DynamoDB registry   │◄─── host heartbeat ─│  ┌────────┐      ┌────────┐ │
  │  (Part 4)            │     + capacity      │  │microVM │ .... │microVM │ │
  └─────────────────────┘                      │  │ agent  │      │ agent  │ │
        ▲                                       │  └────────┘      └────────┘ │
        │ placement lookup                      └──────────────┬──────────────┘
        │                                                      │
  ┌─────┴───────────────┐   agent.fleet.example.com    ┌───────▼──────────────┐
  │  ALB + router        │◄──────────────────────────── │  tap devices + NAT   │
  │  (Part 5)            │      client requests          │  per microVM         │
  └─────────────────────┘                                └──────────────────────┘
          all of it inside one VPC — hosts private, ALB + NAT public

Read it as a loop. A developer builds their agent into a bootable image (Part 3) and calls the control plane (Part 4). The control plane picks a host in the fleet (Part 2) and records where the agent lives. That host pulls the image and boots a microVM. From then on, client requests hit the routing layer (Part 5), which asks the registry where the agent is and forwards the request to the right microVM on the right host.

Why Bare Metal Decides Everything

One hard constraint shapes this entire design, and it is the same one from the single-VM guide: Firecracker needs /dev/kvm, and /dev/kvm is only available on bare-metal EC2 instances. You cannot run a microVM on an ordinary m5.large.

That single fact is why the fleet is an Auto Scaling Group of *.metal instances rather than a normal container platform, and it drives three consequences you feel throughout the series:

  • Hosts are big and coarse-grained. An m5.metal is 96 vCPUs and 384 GiB of RAM. You do not scale the fleet one small node at a time; you add and drain whole large hosts. That makes packing — fitting many microVMs onto each host before adding another — the thing that determines your bill.
  • Hosts are the unit of capacity. Because each host can run dozens of microVMs, the control plane's job is bin-packing: track each host's free vCPU and memory, and place each new agent on a host that has room. That registry is the core of Part 4.
  • The boundary you are paying for is real. The reason to do this at all — rather than running agents as plain containers — is that each Firecracker microVM is a separate virtualized guest with its own kernel. That is a hardware-enforced isolation boundary between one developer's agent and another's, which is exactly what you want when the agents run arbitrary tool code. The whole platform exists to make that boundary cheap to hand out.

The Series Roadmap

We are breaking this into focused parts. Each one deploys on its own, and each builds on the network from this part. You can track the whole series — and jump to any published part — from the series hub. The plan:

  1. Architecture and the network foundation (this part) — the fleet's VPC, subnets, and security groups in CDK.
  2. The host fleet — an Auto Scaling Group of bare-metal hosts, a launch template whose user data installs Firecracker and the host-agent daemon, and the IAM role each host runs under. Hosts register themselves with the control plane on boot.
  3. Packaging an eve agent as a microVM image — a build-rootfs.sh that turns an eve agent directory into a bootable ext4 rootfs, plus a versioned S3 artifact bucket the hosts pull from. Where snapshots come in for fast boots.
  4. The control plane — API Gateway and Lambda over a DynamoDB registry of hosts, agents, and placements, with EventBridge driving the lifecycle. This is the scheduler that turns a deploy request into a placement decision.
  5. Networking and routing — per-microVM tap devices and NAT on each host, an ALB out front, and a per-agent addressing scheme (subdomain per agent) so requests reach the right microVM wherever it landed.
  6. The deploy workflow — tying it together into the deploy my-agent developer experience, and wiring the same call into CI so a merge ships an agent.
  7. Fleet operations — scaling the host fleet on real microVM demand, health-checking and rescheduling agents when a host dies, per-agent logs and metrics, and the cost model (plus how to tear the whole thing down).

If you only care about one layer you can skip ahead once it is published. But the network comes first, because everything else lives inside it, and re-addressing a VPC after you have a fleet running in it is genuinely painful.

Why CDK, and Why a Separate Network Stack

You could click a VPC together in the console in five minutes. We are not going to, for the same reason the single-VM guide used CDK: the fleet is not a one-off you build once and forget — it is a system that will grow hosts, gain a control plane, and get torn down and rebuilt in testing. That is exactly the thing you want defined as a reviewable TypeScript program with a cdk diff before every change, not a pile of console clicks nobody can reproduce.

We also split the network into its own stack, separate from the host-fleet, control-plane, and routing stacks that later parts add. The network changes rarely and is expensive to recreate; the fleet and its services change constantly. Separate stacks mean a routine change to the host launch template can never accidentally propose replacing your VPC, and the later stacks consume this VPC as an input rather than redefining it.

Project Setup

You need Node.js 20+, the AWS CLI configured with credentials, and the CDK toolkit. Bootstrap the account once per region:

npm install -g aws-cdk
mkdir eve-firecracker-fleet && cd eve-firecracker-fleet
cdk init app --language typescript
cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
npm install aws-cdk-lib constructs

We keep stacks in lib/ and shared configuration in one place, so values like the fleet name and CIDR are defined once and reused by every stack in the series:

// lib/config.ts
export const config = {
  /** Reused as a resource-name prefix across every stack in the fleet. */
  fleetName: "eve-fleet",
  region: "us-east-1",
  /** VPC address space. A /16 leaves ample room for a growing host fleet. */
  cidr: "10.0.0.0/16",
  /** Availability zones to spread the fleet across. */
  maxAzs: 2,
  /**
   * NAT gateways. Agents need outbound egress (model APIs, npm, git), but
   * inbound is never direct — it always comes through the ALB. One NAT is
   * cheaper (~$32/mo + data); set this to maxAzs for production HA.
   */
  natGateways: 1,
} as const;

The Three Security Groups Are the Real Design

The subnet layout here is unremarkable — public subnets for the ALB and NAT, private subnets for the hosts. What actually encodes the fleet's security model is the security groups, because they are where "the control plane can reach the hosts, the ALB can reach the agents, and nothing else can reach anything" becomes real rules.

There are three, and each maps to one of the three moving parts:

  • The host security group — attached to every bare-metal host. It allows inbound only from the ALB (to reach the agent microVMs) and from itself (so the host agents can gossip if needed), and it does not open SSH. We reach hosts through SSM Session Manager, exactly as the single-VM guide did, so there is no inbound SSH hole and no key pair to manage across a fleet.
  • The ALB security group — attached to the load balancer. It allows inbound HTTPS from the internet and is allowed to talk to the host security group. This is the only path from the public internet to a running agent.
  • The control-plane security group — attached to the Lambda functions' VPC networking. It needs no inbound rules at all; it only makes outbound calls (to DynamoDB via a VPC endpoint, and to hosts). The interesting rule lives on the host side: hosts accept management traffic from this group.

The Network Stack

Here is the complete network stack. We walk through the decisions below it.

// lib/fleet-network-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import { config } from "./config";

export class FleetNetworkStack extends cdk.Stack {
  /** Exposed so every later stack can place resources in the same VPC. */
  public readonly vpc: ec2.Vpc;
  public readonly albSg: ec2.SecurityGroup;
  public readonly hostSg: ec2.SecurityGroup;
  public readonly controlPlaneSg: ec2.SecurityGroup;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    this.vpc = new ec2.Vpc(this, "Vpc", {
      ipAddresses: ec2.IpAddresses.cidr(config.cidr),
      maxAzs: config.maxAzs,
      natGateways: config.natGateways,
      subnetConfiguration: [
        {
          name: "public",
          subnetType: ec2.SubnetType.PUBLIC,
          cidrMask: 24, // ALB + NAT only — small is fine
          mapPublicIpOnLaunch: false,
        },
        {
          name: "hosts",
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
          cidrMask: 20, // bare-metal hosts + their microVM tap networks
        },
      ],
    });

    // --- The three security groups ---
    this.albSg = new ec2.SecurityGroup(this, "AlbSg", {
      vpc: this.vpc,
      description: "Fleet ALB — public HTTPS in",
      allowAllOutbound: true,
    });
    this.albSg.addIngressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(443),
      "HTTPS from the internet"
    );

    this.controlPlaneSg = new ec2.SecurityGroup(this, "ControlPlaneSg", {
      vpc: this.vpc,
      description: "Control-plane Lambdas — egress only",
      allowAllOutbound: true,
    });

    this.hostSg = new ec2.SecurityGroup(this, "HostSg", {
      vpc: this.vpc,
      description: "Bare-metal Firecracker hosts — no direct SSH",
      allowAllOutbound: true, // agents need egress: model APIs, npm, git
    });
    // The ALB reaches agent microVMs on the host's proxy port range.
    this.hostSg.addIngressRule(
      this.albSg,
      ec2.Port.tcpRange(8000, 8999),
      "Agent traffic from the ALB"
    );
    // The control plane manages microVMs on the host agent's API port.
    this.hostSg.addIngressRule(
      this.controlPlaneSg,
      ec2.Port.tcp(7000),
      "Placement commands from the control plane"
    );
    // Hosts talk to each other (host-agent coordination).
    this.hostSg.addIngressRule(
      this.hostSg,
      ec2.Port.tcp(7000),
      "Host-agent coordination"
    );

    this.addVpcEndpoints();

    new cdk.CfnOutput(this, "VpcId", { value: this.vpc.vpcId });
  }

  /**
   * Gateway endpoints are free and keep high-volume traffic off NAT. Hosts
   * pull multi-hundred-MB rootfs images from S3 constantly; the control-plane
   * Lambdas hit DynamoDB on every request. Both should stay private.
   */
  private addVpcEndpoints() {
    this.vpc.addGatewayEndpoint("S3Endpoint", {
      service: ec2.GatewayVpcEndpointAwsService.S3,
    });
    this.vpc.addGatewayEndpoint("DynamoDbEndpoint", {
      service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
    });
  }
}

And the app entrypoint that instantiates it:

// bin/app.ts
import * as cdk from "aws-cdk-lib";
import { FleetNetworkStack } from "../lib/fleet-network-stack";
import { config } from "../lib/config";

const app = new cdk.App();
const env = { region: config.region };

new FleetNetworkStack(app, "EveFleetNetwork", { env });

Walking Through the Decisions

Hosts live in PRIVATE_WITH_EGRESS subnets. A bare-metal host is expensive and runs untrusted agent code — it should never be directly reachable from the internet. The private subnet gives it outbound access (to pull rootfs images, and so the agents inside can reach model APIs and package registries) while keeping it unreachable inbound except through the security-group rules we defined. The only public subnets hold the ALB and the NAT gateways.

Security-group references, not CIDR ranges. Every rule that connects two tiers uses a security group as the peer — this.hostSg.addIngressRule(this.albSg, ...) — not an IP range. This is the single most important habit in fleet networking: hosts come and go as the ASG scales, so their IPs are never stable, but "anything in the ALB security group" is a rule that stays correct no matter how many hosts exist. The rules describe roles, not addresses.

No SSH, anywhere. The host security group opens no port 22. Across a fleet, SSH keys are a liability — one leaked key reaches every host. We use SSM Session Manager for the rare times a human needs to get onto a host (Part 2 grants the hosts the SSM role), so there is nothing inbound to attack and nothing to rotate.

A port range for agents, one port for control. Each host runs many microVMs, and the host agent proxies each one on a distinct local port (8000–8999 in this design). The ALB reaches those; that is the data path. The single management port (7000) is how the control plane tells a host "boot agent X" or "tear down agent Y" — a separate path with a separate rule, so agent traffic and control traffic are never confused. The exact proxying mechanism is Part 5's job; here we just carve out the security-group space for it.

Gateway endpoints for S3 and DynamoDB. These two are free and eliminate the two highest-volume flows in the platform from the NAT bill: hosts pulling rootfs images from S3, and the control-plane Lambdas reading and writing the registry in DynamoDB. On a busy fleet this is real money, and it keeps that traffic off the public internet entirely.

Deploy It

# See exactly what will be created before creating it.
cdk diff EveFleetNetwork

# Provision the network.
cdk deploy EveFleetNetwork

The deploy takes a few minutes — the NAT gateway is the slow part. When it finishes, CDK prints the VpcId. Confirm the shape of what you built:

aws ec2 describe-subnets \
  --filters "Name=vpc-id,Values=<VpcId>" \
  --query "Subnets[].{AZ:AvailabilityZone,CIDR:CidrBlock,Public:MapPublicIpOnLaunch}" \
  --output table

You should see public /24 and private /20 subnets across your availability zones. That is the foundation Part 2 launches the host fleet into.

Tearing Down

Because this is a series you will build and rebuild, the network stack on its own tears down cleanly:

cdk destroy EveFleetNetwork

The same warning from any multi-stack build applies once later parts exist: the ALB and the host ENIs live inside this VPC, and some are created outside CDK's knowledge. Tear down in reverse order — routing, then control plane, then host fleet, then network — or the VPC deletion will hang on dependencies. We repeat this reminder where it bites.

What's Next

You now have the network the fleet lives in: private subnets for the bare-metal hosts, public subnets for the ALB and NAT, and — the part that actually matters — three security groups that encode who is allowed to talk to whom, written as role-to-role rules that stay correct as hosts come and go.

In Part 2 we put the first hosts into it: an Auto Scaling Group of bare-metal instances, a launch template whose user data installs Firecracker and a host-agent daemon, and the IAM role that lets each host pull agent images and register itself with the control plane. That is where the fleet stops being an empty network and starts being a pool of machines waiting for agents.

If you have not seen the single-host version yet, read Running an eve agent in a Firecracker microVM on AWS first — this series assumes you have booted one microVM by hand and want to know how to run a hundred of them.