Running a Fleet of Firecracker microVMs for eve.dev Agents, Part 6: The Deploy Workflow
By the end of Part 5 the platform did everything: it built agent images, placed them on hosts, and served them by name. But using it was still a sequence of manual steps — run build-rootfs.sh, aws s3 cp the result, curl the control-plane API, poll GET /agents/{id} until it flipped to running, then remember the URL. A platform your team actually adopts hides all of that behind one command. That is what Part 6 builds.
Two things turn the moving parts into a product: a CLI that chains the steps into deploy my-agent, and a CI pipeline that runs the same chain automatically so shipping an agent is just merging a pull request. Before either can face the outside world, though, the control-plane API from Part 4 needs a lock on the door.
First, Lock the API Down
We left Part 4's HTTP API open so the walkthrough could curl it. That is fine on your laptop against a lab account; it is not fine the moment a CI system — or anyone who guesses the URL — can deploy agents onto your fleet. Before automating deploys, put IAM authorization on the routes so every caller must present valid, signed AWS credentials:
// lib/control-plane-stack.ts — add an authorizer to the routes from Part 4
import * as authorizers from "aws-cdk-lib/aws-apigatewayv2-authorizers";
const iamAuth = new authorizers.HttpIamAuthorizer();
httpApi.addRoutes({
path: "/agents",
methods: [apigw.HttpMethod.POST, apigw.HttpMethod.GET],
integration: integ,
authorizer: iamAuth,
});
httpApi.addRoutes({
path: "/agents/{id}",
methods: [apigw.HttpMethod.GET, apigw.HttpMethod.DELETE],
integration: integ,
authorizer: iamAuth,
});
Now every request must be SigV4-signed, and API Gateway checks the caller's IAM identity has execute-api:Invoke on the route before the Lambda ever runs. Developers deploy with their own AWS credentials; CI deploys with a scoped role (below). No shared secret, no API key to leak — the same IAM that governs the rest of the account now governs who can ship an agent.
The Deploy CLI
The CLI is a thin wrapper — it does not reimplement anything, it just chains the four steps you were running by hand and waits for the result. A shell script is enough and keeps the dependencies to aws and curl:
#!/usr/bin/env bash
# fleet-deploy <agent-dir> — build, ship, place, and print the URL.
set -euo pipefail
AGENT_DIR="$1"
AGENT_NAME="$(basename "$AGENT_DIR")"
VERSION="$(git -C "$AGENT_DIR" rev-parse --short HEAD)"
API="$(aws cloudformation describe-stacks --stack-name EveFleetControlPlane \
--query "Stacks[0].Outputs[?OutputKey=='ControlApiUrl'].OutputValue" --output text)"
# 1. Build the versioned rootfs and push it to S3 (Part 3).
./build-rootfs.sh "$AGENT_DIR" "$VERSION"
# 2. Ask the control plane to deploy it (Part 4). SigV4-signed for the IAM auth above.
resp="$(awscurl --service execute-api -X POST "$API/agents" \
-d "{\"name\":\"$AGENT_NAME\",\"version\":\"$VERSION\",\"vcpu\":2,\"mem\":1024}")"
agent_id="$(echo "$resp" | jq -r .agentId)"
# 3. Poll until the scheduler has placed and booted it.
echo "deploying $agent_id ..."
for _ in $(seq 1 60); do
status="$(awscurl --service execute-api "$API/agents/$agent_id" | jq -r .status)"
[ "$status" = "running" ] && break
[ "$status" = "failed" ] && { echo "deploy failed"; exit 1; }
sleep 2
done
# 4. Print the URL the routing layer (Part 5) serves it at.
echo "✔ https://$AGENT_NAME.fleet.example.com"
That is the whole developer experience: fleet-deploy ./my-agent and, thirty seconds later, a URL. The awscurl calls sign each request with the caller's AWS credentials, so the IAM authorizer is satisfied by whoever is logged in. Everything the earlier parts built — the image pipeline, the bin-packing scheduler, the routing mesh — runs underneath those four steps, invisible.
Shipping on Merge with GitHub Actions
The same chain belongs in CI, so that merging a change to an agent ships it with no human running commands. Two details make this production-grade rather than a demo: authenticate to AWS with OIDC (no long-lived access keys stored in the repo), and assume a scoped role that can only invoke the control-plane API and write to the image bucket — nothing else.
# .github/workflows/deploy.yml
name: Deploy agent
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.ACCOUNT_ID }}:role/fleet-deployer
aws-region: us-east-1
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y debootstrap jq && pip install awscurl
- name: Deploy
run: ./fleet-deploy ./my-agent
The fleet-deployer role is the CI counterpart to a developer's credentials — it trusts the GitHub OIDC provider, is scoped to this repository, and holds exactly the two permissions the deploy needs: execute-api:Invoke on the control-plane API and write access to the agents/* prefix of the image bucket. A leaked workflow cannot touch the hosts, the network, or anyone else's agents.
A Preview Agent per Pull Request
Here is where owning the platform pays off in a way a hosted product cannot match. Because an agent's name determines its subdomain (Part 5's wildcard DNS), you can give every pull request its own live agent just by deriving the name from the PR number — no new infrastructure, no DNS change, just a different string:
# .github/workflows/preview.yml
on:
pull_request:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.ACCOUNT_ID }}:role/fleet-deployer
aws-region: us-east-1
- name: Deploy preview
run: ./fleet-deploy ./my-agent --name "my-agent-pr-${{ github.event.number }}"
- name: Comment the URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
...context.repo, issue_number: context.issue.number,
body: `Preview agent: https://my-agent-pr-${context.issue.number}.fleet.example.com`
})
Open a PR against your agent and CI deploys my-agent-pr-42.fleet.example.com — a real, isolated agent in its own microVM that reviewers can talk to before the change merges. The scheduler bin-packs it onto whatever host has room; it costs one more microVM slice, not a new host. A companion cleanup workflow on pull_request: closed calls DELETE /agents/my-agent-pr-42 so previews do not accumulate — the scheduler frees the capacity and the routing mesh forgets the name. This is the same preview-deployment ergonomics people love about Vercel, running on infrastructure you own.
Rollback Is a Redeploy
Because every build is an immutable version and the control plane records which version a placement runs (Part 3, Part 4), rolling back is just deploying an older version — no special path:
fleet-deploy ./my-agent --version <previous-sha>
The control plane places the old image, the routing layer cuts over to the new microVM once it is healthy, and the bad version's microVM is drained. Same command, same safety, in reverse.
What's Next
The platform is now something a team uses without thinking about it: fleet-deploy from a laptop, an agent shipped on every merge, a preview agent on every pull request, and a one-command rollback — all on top of the build, placement, and routing the earlier parts built.
What is left is keeping it healthy when you are not watching. In Part 7, the finale, we make the fleet operable: scaling the host pool up when the scheduler runs out of room and draining it back down when demand falls, a reconciliation loop that reschedules agents off a dead host, per-agent logs and metrics rolled up across every host, and the FinOps view that answers what the whole thing costs — plus how to tear the entire platform down cleanly.
For the agent-side of this loop — what actually lives in the my-agent directory this pipeline ships — the eve framework quickstart is the shortest path to a directory worth deploying.