DEV Community

Cover image for Lambda MicroVMs: run AI code safely in a sandbox
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Lambda MicroVMs: run AI code safely in a sandbox

Lambda MicroVMs are the building block I had been waiting for to solve one very specific problem: running code that I did not write myself. An AI assistant suggests a solution, an agent wants to test it, a learning platform lets beginners write their first loops. Simply running that code inside your own Lambda function would be reckless. Operating a container cluster just for sandboxes, on the other hand, feels a lot like “servers are back”.

On November 11, 2026, AWS added x86 support to MicroVMs, and that was my reason to take a thorough look. In this article we build a small code sandbox together: one MicroVM per learning session, controlled by a Lambda function that you deploy with the Serverless Framework. On top of that you get my honest take on where the model shines and where you are better off sticking with regular functions.

In short

Lambda MicroVMs start an isolated VM with its own HTTPS endpoint from a snapshot. It lives for up to eight hours and is put to sleep when idle. This lets you build sandboxes for AI-generated or third-party code without running servers. The biggest pitfall: outbound internet access is open by default, and for untrusted code you have to close it deliberately.

What you will learn in this article

  • What Lambda MicroVMs are and how they differ from Lambda functions
  • How the snapshot, the /ready hook and the /run hook work together
  • How to build a code sandbox step by step with TypeScript and the Serverless Framework
  • Which security settings are mandatory for untrusted code
  • What a session costs

What are Lambda MicroVMs?

A MicroVM is a very lightweight virtual machine. AWS builds it on Firecracker, the same virtualization technology that regular Lambda functions run on. What is new is that you now control the VM yourself: you start it, it gets its own HTTPS endpoint, it keeps its memory and disk state, and when it is idle it is put to sleep instead of being thrown away.

The trick behind this is the snapshot. When you create a MicroVM image, Lambda runs your Dockerfile, starts your application and waits until it reports that it is ready. Then Lambda freezes the complete state of memory and disk. Every new MicroVM starts from this frozen state, so your application does not have to boot again. If you know SnapStart, you will recognize the principle.

Two more terms come up all the time. Suspend means the VM is paused, memory and disk are kept, and you only pay for storing the state. Resume wakes it up again, automatically on the next request if you want. In addition, there are hooks: HTTP endpoints in your application that Lambda calls at fixed points in the lifecycle, for example /ready during the build or /run when a new VM starts.

Lambda function or MicroVM: the difference

Lambda function Lambda MicroVM
Invocation event or invoke, Lambda distributes the load own HTTPS endpoint per VM, no load balancer
Duration at most 15 minutes per invocation up to 8 hours including suspend
State ephemeral, no guarantee between invocations memory and disk survive a suspend
Isolation execution environment is reused for invocations of the same function one VM per session, job or tenant, full operating system
Billing per request and duration baseline size per second while RUNNING, storage cost while suspended

Bottom line: functions are built for many short, similar invocations. MicroVMs are meant for longer, stateful sessions in which someone (or something) brings code you do not trust.

When are Lambda MicroVMs worth it?

Among other use cases, AWS lists sandboxes for AI code, interactive development environments, notebooks, security scans and multi-tenant CI/CD runners. I would make the decision with three questions.

First: are you running code you do not control? If yes, a dedicated VM per session is the cleanest boundary you can get with serverless. Second: does the session need state for minutes or hours, for example installed packages, an open database connection or a conversation with an agent? That is where suspend and resume shine. Third: do you need operating system capabilities that a function does not offer, such as system packages or your own file systems?

If the answer is “no” three times, stay with functions. A REST API that accepts orders only gets more expensive and more complicated with MicroVMs. That is why I see MicroVMs as an addition to the toolbox and not as a replacement for Lambda functions.

Diagram: a learning app calls an orchestrator via HTTP API, which starts one Lambda MicroVM per session

The example architecture: a Lambda function starts a dedicated MicroVM for each learning session and talks to it via its HTTPS endpoint.

Lifecycle: snapshot, suspend, resume

Before we build anything, it pays to look at the flow, because it shapes the architecture. A MicroVM starts from the snapshot with run-microvm and ends up in the RUNNING state. If no traffic arrives for a configurable time, it moves to SUSPENDED. A new request wakes it up again; Lambda briefly holds the request and delivers it afterwards. If it stays suspended for too long or exceeds its maximum lifetime, Lambda terminates it. You can of course also terminate it yourself.

🎬 Lifecycle of a Lambda MicroVM – this animated diagram only runs on my site: watch it on sascha-ms.de

One detail made me pause while reading the docs: because all VMs start from the same snapshot, they initially share everything that was in memory at build time. A session ID, a random value or a counter that you create at startup would be identical in every VM. This is exactly what the /run hook is for: it runs once per new VM before any traffic arrives. For unique values, AWS also recommends a cryptographically secure random number generator.

The example: a code sandbox for a learning platform

As an example I use a made-up learning platform called “Codegarden”. Learners write small JavaScript exercises there, an AI tutor suggests solutions, and both should run at the push of a button. Every learning session gets its own MicroVM. Inside it runs a tiny HTTP server that accepts snippets and executes them with a timeout and an output limit.

In front of the MicroVM sits an orchestrator: a Lambda function behind an HTTP API that creates sessions, forwards snippets and ends sessions. The mapping from session to MicroVM lives in DynamoDB. The orchestrator creates the auth token for the VM endpoint itself and never passes it on to the browser.

Step by step: building a sandbox with Lambda MicroVMs

Prerequisites

  • An AWS account in a region where Lambda MicroVMs are available (the examples use us-east-1)
  • A current AWS CLI v2 that knows the aws lambda-microvms command
  • Node.js 24 locally (for the tests, Node.js 22.6 with --experimental-strip-types is enough)
  • Serverless Framework v4 (npm i -g serverless) and an S3 bucket for the code artifact
  • Basic knowledge of TypeScript and IAM

Step 1: Write the sandbox runner

The runner consists of two files. execute.ts writes the snippet into a fresh temp directory, starts a separate Node.js process without environment variables and kills it hard if it takes too long or prints too much.

// sandbox/execute.ts
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

export interface SnippetResult {
  exitCode: number | null;
  stdout: string;
  stderr: string;
  timedOut: boolean;
  truncated: boolean;
  durationMs: number;
}

export interface ExecuteOptions {
  timeoutMs?: number;
  maxOutputBytes?: number;
}

// Runs a JavaScript snippet in a separate Node.js process.
// The security boundary is the MicroVM, this process only provides
// timeouts, output limits and a fresh working directory per run.
export async function executeSnippet(
  code: string,
  { timeoutMs = 5_000, maxOutputBytes = 32_768 }: ExecuteOptions = {},
): Promise<SnippetResult> {
  const workDir = await mkdtemp(join(tmpdir(), 'snippet-'));
  const file = join(workDir, 'main.mjs');
  await writeFile(file, code, 'utf8');

  const started = Date.now();
  const child = spawn(process.execPath, ['--max-old-space-size=256', file], {
    cwd: workDir,
    env: { PATH: '/usr/local/bin:/usr/bin:/bin' },
    stdio: ['ignore', 'pipe', 'pipe'],
  });

  let stdout = '';
  let stderr = '';
  let truncated = false;
  const collect = (chunk: Buffer, target: 'out' | 'err') => {
    const used = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
    const room = maxOutputBytes - used;
    if (room <= 0) {
      truncated = true;
      return;
    }
    const text = chunk.subarray(0, room).toString('utf8');
    if (chunk.length > room) truncated = true;
    if (target === 'out') stdout += text;
    else stderr += text;
  };
  child.stdout.on('data', (c: Buffer) => collect(c, 'out'));
  child.stderr.on('data', (c: Buffer) => collect(c, 'err'));

  let timedOut = false;
  const timer = setTimeout(() => {
    timedOut = true;
    child.kill('SIGKILL');
  }, timeoutMs);

  const exitCode = await new Promise<number | null>((resolve) => {
    child.on('close', (code) => resolve(code));
  });
  clearTimeout(timer);
  await rm(workDir, { recursive: true, force: true });

  return { exitCode, stdout, stderr, timedOut, truncated, durationMs: Date.now() - started };
}
Enter fullscreen mode Exit fullscreen mode

The child process is explicitly not the security boundary here. It makes sure that an endless loop does not block the whole session. The real isolation comes from the MicroVM: whatever a snippet does inside it stays in that one VM.

server.ts is the HTTP server on port 8080. It answers the two hooks and the actual /snippets endpoint. Pay attention to the /run hook: that is where we reset the state that would otherwise come from the snapshot.

// sandbox/server.ts
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { executeSnippet } from './execute.ts';

const PORT = Number(process.env.PORT ?? 8080);
const HOOKS = '/aws/lambda-microvms/runtime/v1';
const MAX_BODY_BYTES = 64 * 1024;
const MAX_RUNS_PER_SESSION = 200;

// State per MicroVM. It is captured in the snapshot and therefore
// reset in the /run hook for every new MicroVM.
let sessionLabel = 'unknown';
let runCount = 0;

async function readBody(req: IncomingMessage): Promise<string> {
  let size = 0;
  const chunks: Buffer[] = [];
  for await (const chunk of req) {
    size += (chunk as Buffer).length;
    if (size > MAX_BODY_BYTES) throw new Error('Request body too large');
    chunks.push(chunk as Buffer);
  }
  return Buffer.concat(chunks).toString('utf8');
}

function send(res: ServerResponse, status: number, body: unknown): void {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(body));
}

const server = createServer(async (req, res) => {
  try {
    // Build hook: snapshot may be taken as soon as we answer 200
    if (req.method === 'POST' && req.url === `${HOOKS}/ready`) {
      return send(res, 200, { ready: true });
    }
    // Runtime hook: called once per new MicroVM, before any traffic
    if (req.method === 'POST' && req.url === `${HOOKS}/run`) {
      const { microvmId, runHookPayload } = JSON.parse((await readBody(req)) || '{}');
      sessionLabel = String(runHookPayload ?? microvmId ?? 'unknown');
      runCount = 0;
      console.log(`Sandbox started for session ${sessionLabel}`);
      return send(res, 200, { ok: true });
    }
    if (req.method === 'GET' && req.url === '/health') {
      return send(res, 200, { status: 'ok', session: sessionLabel, runs: runCount });
    }
    if (req.method === 'POST' && req.url === '/snippets') {
      if (runCount >= MAX_RUNS_PER_SESSION) {
        return send(res, 429, { error: 'Run limit for this session reached' });
      }
      const { code } = JSON.parse(await readBody(req));
      if (typeof code !== 'string' || code.length === 0) {
        return send(res, 400, { error: 'Field "code" is missing' });
      }
      runCount += 1;
      const result = await executeSnippet(code, { timeoutMs: 5_000 });
      return send(res, 200, { run: runCount, ...result });
    }
    return send(res, 404, { error: 'Not found' });
  } catch (err) {
    return send(res, 400, { error: (err as Error).message });
  }
});

server.listen(PORT, () => console.log(`Sandbox runner listening on port ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

Step 2: Test the runner locally

Before anything goes to the cloud, we check the execution logic locally. I use the built-in Node.js test runner so the image does not need any extra dependencies.

// sandbox/execute.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { executeSnippet } from './execute.ts';

test('returns the output of a snippet', async () => {
  const result = await executeSnippet('console.log(6 * 7)');
  assert.equal(result.exitCode, 0);
  assert.equal(result.stdout.trim(), '42');
  assert.equal(result.timedOut, false);
});

test('reports errors on stderr', async () => {
  const result = await executeSnippet('throw new Error("broken")');
  assert.notEqual(result.exitCode, 0);
  assert.match(result.stderr, /broken/);
});

test('stops endless loops after the timeout', async () => {
  const result = await executeSnippet('while (true) {}', { timeoutMs: 500 });
  assert.equal(result.timedOut, true);
  assert.ok(result.durationMs < 3_000);
});

test('cuts off excessive output', async () => {
  const result = await executeSnippet('for (;;) console.log("x".repeat(1000))', {
    timeoutMs: 1_000,
    maxOutputBytes: 2_048,
  });
  assert.equal(result.truncated, true);
  assert.ok(result.stdout.length <= 2_048);
});

test('does not pass environment variables to the snippet', async () => {
  process.env.SECRET_TEST_VALUE = 'top-secret';
  const result = await executeSnippet('console.log(process.env.SECRET_TEST_VALUE ?? "none")');
  assert.equal(result.stdout.trim(), 'none');
});
Enter fullscreen mode Exit fullscreen mode
node --experimental-strip-types --test execute.test.ts
Enter fullscreen mode Exit fullscreen mode

Expected result: # pass 5 and # fail 0. On Node.js 24 you can drop the flag. You can also start the server locally and call the hooks with curl before you package it into an image.

PORT=8091 node server.ts
curl -s -X POST localhost:8091/aws/lambda-microvms/runtime/v1/run -d '{"runHookPayload":"test-session"}'
curl -s -X POST localhost:8091/snippets -d '{"code":"console.log([1,2,3].map(n => n * 2))"}'
Enter fullscreen mode Exit fullscreen mode

The response should contain "exitCode":0 and "stdout":"[ 2, 4, 6 ]\n".

Step 3: Dockerfile and hooks

The Dockerfile is deliberately simple. The base comes from node:24-alpine, the runner runs as an unprivileged user, and the runner files are read-only. You choose the operating system of the MicroVM itself separately later, via a base image managed by Lambda.

# sandbox/Dockerfile
FROM node:24-alpine

WORKDIR /app
COPY execute.ts server.ts ./

# Run as unprivileged user, snippets cannot change the runner files
RUN chown -R root:root /app && chmod -R a-w /app
USER node

EXPOSE 8080
# Node.js 24 runs TypeScript directly (type stripping)
CMD ["node", "server.ts"]
Enter fullscreen mode Exit fullscreen mode

For Lambda to call our hooks, we need a small configuration. It says: hooks run on port 8080, /ready is called during the build and /run at runtime.

// sandbox/hooks.json
{
  "port": 8080,
  "microvmImageHooks": { "ready": "ENABLED", "readyTimeoutInSeconds": 60 },
  "microvmHooks": { "run": "ENABLED", "runTimeoutInSeconds": 10 }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Create the build role

While building the image, Lambda assumes an IAM role to read the artifact from S3 and write build logs. Without this role you get no logs, and you will definitely need them for your first failed build.

// trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "lambda.amazonaws.com" },
    "Action": ["sts:AssumeRole", "sts:TagSession"]
  }]
}
Enter fullscreen mode Exit fullscreen mode
aws iam create-role --role-name CodegardenBuildRole \
  --assume-role-policy-document file://trust-policy.json
aws iam put-role-policy --role-name CodegardenBuildRole --policy-name build \
  --policy-document '{"Version":"2012-10-17","Statement":[
    {"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::YOUR-BUCKET/codegarden/*"},
    {"Effect":"Allow","Action":["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"],"Resource":"*"}]}'
Enter fullscreen mode Exit fullscreen mode

Step 5: Build the MicroVM image

The script packs the Dockerfile and the runner into a ZIP, uploads it to S3 and starts the build. Without further options, Lambda builds for Arm (Graviton). For x86 you add --cpu-configurations architecture=X86_64; according to the docs this requires base image version 3 or later.

#!/usr/bin/env bash
# sandbox/build-image.sh
set -euo pipefail

REGION="${AWS_REGION:-us-east-1}"
BUCKET="${ARTIFACT_BUCKET:?Please set ARTIFACT_BUCKET}"
ROLE_ARN="${BUILD_ROLE_ARN:?Please set BUILD_ROLE_ARN}"

zip -j runner.zip Dockerfile execute.ts server.ts
aws s3 cp runner.zip "s3://${BUCKET}/codegarden/runner.zip"

aws lambda-microvms create-microvm-image \
  --region "$REGION" \
  --name codegarden-runner \
  --code-artifact "uri=s3://${BUCKET}/codegarden/runner.zip" \
  --base-image-arn "arn:aws:lambda:${REGION}:aws:microvm-image:al2023-1" \
  --build-role-arn "$ROLE_ARN" \
  --hooks file://hooks.json
Enter fullscreen mode Exit fullscreen mode
export ARTIFACT_BUCKET=your-bucket
export BUILD_ROLE_ARN=arn:aws:iam::123456789012:role/CodegardenBuildRole
./build-image.sh
aws lambda-microvms get-microvm-image --image-identifier codegarden-runner
Enter fullscreen mode Exit fullscreen mode

The build takes a while. Expected result: the state changes from CREATING to CREATED. If it says CREATE_FAILED, you will find the build logs in CloudWatch under /aws/lambda-microvms/codegarden-runner.

Step 6: Build the orchestrator with the Serverless Framework

Now comes the part that feels like familiar serverless work. The serverless.yml describes three functions, a DynamoDB table with TTL and exactly the IAM permissions the orchestrator needs.

# serverless.yml
service: codegarden-sandbox
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs24.x
  architecture: arm64
  region: ${opt:region, 'us-east-1'}
  memorySize: 512
  timeout: 29
  environment:
    SESSIONS_TABLE: !Ref SessionsTable
    IMAGE_ARN: arn:aws:lambda:${aws:region}:${aws:accountId}:microvm-image:codegarden-runner
    # Optional: VPC egress connector without internet route (see article)
    EGRESS_CONNECTOR_ARN: ${param:egressConnectorArn, ''}
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - lambda:RunMicrovm
            - lambda:GetMicrovm
            - lambda:TerminateMicrovm
            - lambda:CreateMicrovmAuthToken
          Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:microvm-image:codegarden-runner
        - Effect: Allow
          Action:
            - dynamodb:GetItem
            - dynamodb:PutItem
            - dynamodb:DeleteItem
          Resource: !GetAtt SessionsTable.Arn

functions:
  createSession:
    handler: src/handlers.createSession
    reservedConcurrency: 5 # protects against a flood of new VMs
    events:
      - httpApi: { method: POST, path: /sessions }
  runSnippet:
    handler: src/handlers.runSnippet
    events:
      - httpApi: { method: POST, path: '/sessions/{id}/snippets' }
  endSession:
    handler: src/handlers.endSession
    events:
      - httpApi: { method: DELETE, path: '/sessions/{id}' }

resources:
  Resources:
    SessionsTable:
      Type: AWS::DynamoDB::Table
      Properties:
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - { AttributeName: sessionId, AttributeType: S }
        KeySchema:
          - { AttributeName: sessionId, KeyType: HASH }
        TimeToLiveSpecification:
          AttributeName: expiresAt
          Enabled: true
Enter fullscreen mode Exit fullscreen mode

The file microvm.ts wraps the calls to the new SDK library @aws-sdk/client-lambda-microvms. Two details matter to me. The clientToken is the session ID, so a repeated call does not start a second VM. And the auth token is valid for only five minutes and only for port 8080.

// src/microvm.ts
import {
  CreateMicrovmAuthTokenCommand,
  GetMicrovmCommand,
  LambdaMicrovmsClient,
  RunMicrovmCommand,
  TerminateMicrovmCommand,
} from '@aws-sdk/client-lambda-microvms';

const client = new LambdaMicrovmsClient({});
const region = process.env.AWS_REGION ?? 'us-east-1';
const connector = (name: string) =>
  `arn:aws:lambda:${region}:aws:network-connector:aws-network-connector:${name}`;

export interface Sandbox {
  microvmId: string;
  endpoint: string;
}

// Starts a fresh MicroVM from the snapshot for one learner session
export async function startSandbox(sessionId: string): Promise<Sandbox> {
  const egress = process.env.EGRESS_CONNECTOR_ARN;
  const result = await client.send(
    new RunMicrovmCommand({
      imageIdentifier: process.env.IMAGE_ARN!,
      clientToken: sessionId, // idempotent: retries do not start a second VM
      runHookPayload: sessionId,
      ingressNetworkConnectors: [connector('HTTP_INGRESS')],
      // Without our own VPC connector the VM would have internet access
      ...(egress ? { egressNetworkConnectors: [egress] } : {}),
      idlePolicy: {
        autoResumeEnabled: true,
        maxIdleDurationSeconds: 600,
        suspendedDurationSeconds: 1800,
      },
      maximumDurationInSeconds: 3 * 60 * 60,
    }),
  );
  return { microvmId: result.microvmId!, endpoint: result.endpoint! };
}

// Waits briefly until the MicroVM accepts traffic
export async function waitUntilReady(microvmId: string, timeoutMs = 15_000): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const { state, stateReason } = await client.send(
      new GetMicrovmCommand({ microvmIdentifier: microvmId }),
    );
    // SUSPENDED is fine: auto-resume holds the first request
    if (state === 'RUNNING' || state === 'SUSPENDED') return;
    if (state === 'TERMINATING' || state === 'TERMINATED') {
      throw new Error(`MicroVM ${microvmId} is no longer available: ${stateReason ?? state}`);
    }
    await new Promise((r) => setTimeout(r, 500));
  }
  throw new Error(`MicroVM ${microvmId} was not ready within ${timeoutMs} ms`);
}

// Short-lived token, valid for port 8080 only
export async function authHeaderFor(microvmId: string): Promise<string> {
  const { authToken } = await client.send(
    new CreateMicrovmAuthTokenCommand({
      microvmIdentifier: microvmId,
      expirationInMinutes: 5,
      allowedPorts: [{ port: 8080 }],
    }),
  );
  const value = authToken?.['X-aws-proxy-auth'];
  if (!value) throw new Error('No auth token received');
  return value;
}

export async function stopSandbox(microvmId: string): Promise<void> {
  await client.send(new TerminateMicrovmCommand({ microvmIdentifier: microvmId }));
}
Enter fullscreen mode Exit fullscreen mode

Storing sessions in DynamoDB is unspectacular. The expiresAt field serves as TTL so that orphaned entries disappear on their own after three hours.

// src/store.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
  DeleteCommand,
  DynamoDBDocumentClient,
  GetCommand,
  PutCommand,
} from '@aws-sdk/lib-dynamodb';

const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TableName = process.env.SESSIONS_TABLE!;

export interface SessionRecord {
  sessionId: string;
  microvmId: string;
  endpoint: string;
  expiresAt: number; // epoch seconds, used as DynamoDB TTL
}

export async function saveSession(record: SessionRecord): Promise<void> {
  await db.send(new PutCommand({ TableName, Item: record }));
}

export async function loadSession(sessionId: string): Promise<SessionRecord | undefined> {
  const { Item } = await db.send(new GetCommand({ TableName, Key: { sessionId } }));
  return Item as SessionRecord | undefined;
}

export async function deleteSession(sessionId: string): Promise<void> {
  await db.send(new DeleteCommand({ TableName, Key: { sessionId } }));
}
Enter fullscreen mode Exit fullscreen mode

The handlers connect everything. The most interesting one is runSnippet: for every execution the orchestrator fetches a fresh token and calls the VM endpoint directly. The browser sees neither the token nor the endpoint.

// src/handlers.ts
import { randomUUID } from 'node:crypto';
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { authHeaderFor, startSandbox, stopSandbox, waitUntilReady } from './microvm.js';
import { deleteSession, loadSession, saveSession } from './store.js';

const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
  statusCode,
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(body),
});

// POST /sessions – one MicroVM per learner session
export async function createSession(): Promise<APIGatewayProxyResultV2> {
  const sessionId = randomUUID();
  const sandbox = await startSandbox(sessionId);
  await saveSession({
    sessionId,
    ...sandbox,
    expiresAt: Math.floor(Date.now() / 1000) + 3 * 60 * 60,
  });
  return json(201, { sessionId });
}

// POST /sessions/{id}/snippets – runs code in the session's sandbox
export async function runSnippet(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const session = await loadSession(event.pathParameters?.id ?? '');
  if (!session) return json(404, { error: 'Session not found' });

  const { code } = JSON.parse(event.body ?? '{}');
  if (typeof code !== 'string' || code.length > 20_000) {
    return json(400, { error: 'Field "code" is missing or too long' });
  }

  await waitUntilReady(session.microvmId);
  const response = await fetch(`https://${session.endpoint}/snippets`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-aws-proxy-auth': await authHeaderFor(session.microvmId),
      'X-aws-proxy-port': '8080',
    },
    body: JSON.stringify({ code }),
    signal: AbortSignal.timeout(20_000),
  });
  return json(response.status, await response.json());
}

// DELETE /sessions/{id} – ends the session and stops all costs
export async function endSession(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const session = await loadSession(event.pathParameters?.id ?? '');
  if (!session) return json(404, { error: 'Session not found' });
  await stopSandbox(session.microvmId);
  await deleteSession(session.sessionId);
  return { statusCode: 204 };
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Deploy and try it out

npm install
npx tsc --noEmit
serverless deploy --region us-east-1
Enter fullscreen mode Exit fullscreen mode

After the deployment, the framework prints the URL of the HTTP API. Use it to play through a complete session:

API=https://abc123.execute-api.us-east-1.amazonaws.com
SESSION=$(curl -s -X POST $API/sessions | jq -r .sessionId)
curl -s -X POST $API/sessions/$SESSION/snippets \
  -H 'Content-Type: application/json' \
  -d '{"code":"const numbers = [3, 1, 2]; console.log(numbers.sort())"}'
curl -s -X DELETE $API/sessions/$SESSION -o /dev/null -w '%{http_code}\n'
Enter fullscreen mode Exit fullscreen mode

Expected result: the first call returns a session ID, the second a JSON with "stdout":"[ 1, 2, 3 ]\n", and the third the status 204. Feel free to try while (true) {} as well. After five seconds you get "timedOut":true back, and the session keeps working.

Step 8: Clean up

Running MicroVMs cost money, even if you forgot about the API. So check at the end whether anything is still running and clean everything up.

aws lambda-microvms list-microvms --image-identifier codegarden-runner
aws lambda-microvms terminate-microvm --microvm-identifier mvm-...
serverless remove --region us-east-1
aws lambda-microvms delete-microvm-image --image-identifier codegarden-runner
Enter fullscreen mode Exit fullscreen mode

Security: what the sandbox really protects

A sandbox is only as good as its weakest setting. The VM isolation takes a lot off your plate, but a few points remain your job.

Restrict internet access deliberately

This is the most important point. According to the docs, MicroVMs have public internet access by default. For a learning platform that means a snippet could send data out, attack other servers or mine cryptocurrency. I did not find a dedicated “no egress” option in the docs. The documented way is a VPC egress connector that routes outbound traffic into your VPC. If you attach it to subnets without a NAT gateway or internet gateway and to a security group without outbound rules, nothing gets out of the VM anymore.

aws lambda-core create-network-connector \
  --name codegarden-no-internet \
  --configuration '{"VpcEgressConfiguration":{
    "SubnetIds":["subnet-isolated-a","subnet-isolated-b"],
    "SecurityGroupIds":["sg-without-egress"],
    "NetworkProtocol":"IPv4",
    "AssociatedComputeResourceTypes":["MicroVm"]}}' \
  --operator-role arn:aws:iam::123456789012:role/NetworkConnectorOperatorRole
serverless deploy --param="egressConnectorArn=arn:aws:lambda:us-east-1:123456789012:network-connector:nc-..."
Enter fullscreen mode Exit fullscreen mode

According to the docs, the operator role needs permission to create and tag network interfaces. After the deployment, use a snippet like await fetch("https://example.com") to check that it really fails. Trust is good, a failing test is better.

No AWS permissions inside the VM

The execution role is optional, and for a sandbox I would leave it out. Without it, the MicroVM has no access to other AWS services. The price: Lambda then writes no runtime logs to CloudWatch. If you need logs, give the role log permissions only and nothing else.

Tokens, limits and abuse

The auth token is the ticket to the VM endpoint. That is why it stays in the orchestrator, is short-lived and is limited to one port. In addition, the runner limits runtime, output and the number of executions, and reservedConcurrency slows down the creation of new sessions. Before real use, the HTTP API also needs authentication, for example a JWT authorizer. Otherwise anyone on the internet can start VMs at your expense.

What do Lambda MicroVMs cost?

You pay for the configured baseline size per second while the VM is running, plus the actual usage above the baseline. A VM can briefly scale up to four times its baseline. While suspended, only storage costs for the state apply, plus fees for writing and reading snapshots.

The pricing page calculates its examples for us-east-1 and Graviton with roughly 0.0000277 US dollars per vCPU second and 0.0000037 US dollars per GB second (as of November 12, 2026). That gives the following rough calculation for a 30-minute session that runs the whole time:

Baseline size per hour 30-minute session
2 GB / 1 vCPU (default) approx. 0.126 USD approx. 0.063 USD
0.5 GB / 0.25 vCPU approx. 0.032 USD approx. 0.016 USD

For a learning platform with many short sessions, the smallest size is usually enough. More important than the size, however, is that sessions end reliably. A forgotten VM runs for up to eight hours, and the idle policy only helps if no traffic arrives anymore. Also add the snapshot fees and the storage for the image, which according to the pricing page is billed for at least one week.

Common mistakes

  • State from the snapshot: IDs or random values created at startup instead of in the /run hook. All VMs then share the same value.
  • Hook holds the connection: if the app is not ready yet during the build, /ready should answer 503 immediately instead of waiting. Otherwise Lambda aborts the build after the timeout.
  • Wrong architecture: an x86 binary in an Arm image fails to start with Exec format error.
  • Asynchronous work without traffic: if the VM computes in the background without incoming requests, the idle policy puts it to sleep. Set the idle time accordingly.
  • Missing quota: the account limit refers to the memory of all running and suspended VMs. On ServiceQuotaExceededException, terminate old VMs or request a higher quota.

My take

I consider Lambda MicroVMs one of the most interesting serverless features in a while, precisely because of AI. Agents that write and try out code need exactly this: a dedicated environment per task that is available quickly, keeps state and disappears without a trace at the end. Until now that usually meant a container cluster, your own Firecracker hosts or an external sandbox provider. Now it is an API call.

At the same time, the model is different from what Lambda people are used to. Suddenly you manage lifecycles, idle times and endpoints. That is more responsibility, and the cost trap “forgotten VM” simply does not exist with functions. That is why I would only use MicroVMs where isolation or long sessions are really needed, and give every session a hard upper limit and a clean ending.

There is one thing I would still like from AWS: a clearly documented option to switch off outbound traffic completely without having to build a VPC. For sandboxes, “no internet” is the sensible default, not the exception. If you want to experiment locally before costs arise, the article on the AWS Emulator is a good introduction to testing serverless code locally.

FAQ

What is the difference between Lambda MicroVMs and Lambda functions?

Functions handle short invocations of at most 15 minutes and Lambda distributes them itself. A MicroVM is a dedicated VM with an HTTPS endpoint, state and a lifetime of up to eight hours that you start, suspend and terminate yourself.

How long can a Lambda MicroVM run?

At most 28,800 seconds, i.e. eight hours. Time spent suspended counts as well. With maximumDurationInSeconds you can set a shorter limit.

Can I run x86 software in Lambda MicroVMs?

Yes, since November 11, 2026. When creating the image, you choose X86_64 via --cpu-configurations. Arm remains the default, and all VMs of an image version inherit its architecture.

Do Lambda MicroVMs have internet access?

By default, yes. With a VPC egress connector you route the traffic into your VPC, where you can restrict or block it completely.

Your next step

Build only the runner first and test it locally, which takes less than half an hour. Then one image, one VM, one curl with a token, and terminate it again. Once that works, add the orchestrator. I am curious: what would you use MicroVMs for, AI agents, CI jobs or something entirely different? Let me know in the comments.

All code from this article, ready to run: smirowstanitzok/sascha-ms-examples

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)