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 }; } 

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}`)); 

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'); }); 
node --experimental-strip-types --test execute.test.ts 

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))"}' 

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"] 

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 } } 

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"] }] } 
Source:https://dev.to/sascha-ms/lambda-microvms-run-ai-code-safely-in-a-sandbox-3g8n

Related posts