DEV Community

Cover image for I Built PR Preview Environments With AWS Lambda MicroVMs and Cut Staging Costs by 78%
Jatin Mehrotra for AWS

Posted on

I Built PR Preview Environments With AWS Lambda MicroVMs and Cut Staging Costs by 78%

On June 22, 2026, AWS released Lambda MicroVMs and I also covered how it works and how your agents can use it

That got me thinking, if Lambda MicroVMs suspend when idle and cost $0 while paused, why not use them as pull request preview environments?

Your typical staging runs 730 hours/month while reviewers use it for 50 minutes. You're paying for 93% idle time. Lambda MicroVMs flip this. Suspend when idle, resume in under 2 seconds, pay $0 while paused. The result: saves 78% vs standalone staging and 93% vs EKS, with zero infrastructure to manage, no cluster upgrades, no ALB configuration.

In this blog, I'll show you how to build dynamic PR environments with Lambda MicroVMs, real cost breakdown, and share the production lessons I learned building this solution.

For the people who want to jump straight in to trying the solution, follow my repo here


Table of Contents

  1. The Staging Bottleneck Problem

  2. Architecture

  3. The Solution

  4. How to Test This

  5. Cost: Lambda MicroVMs vs Shared Staging vs EKS

  6. Production Tips for Lambda MicroVMs

  7. From DevOps Perspective


The Staging Bottleneck Problem

Most enterprise teams have the same problem where staging environments are a bottleneck.

staging problem

Developer A deploys their feature and QA starts testing. Developer B has to wait, then Developer C deploys another change. Suddenly, reviewers are testing multiple developers' code instead of a single pull request. Debugging becomes harder, feedback slows down, and the staging environment keeps running 24/7 even when nobody is using it.

Existing solutions all had trade-offs; shared staging, Kubernetes namespaces with always-on costs, or frontend-only preview platforms like Vercel and Netlify.

What I wanted:

  • Each PR gets its own isolated environment (VM-level, not container)
  • Reviewers click a link in the PR comment and see the app running with that PR's code
  • PR closes, environment dies (data cleaned up, nothing left running)
  • $0 compute cost while idle

Lambda MicroVMs gave me exactly this. Suspend/resume is the killer feature here. The MicroVM suspends after 5 minutes of no traffic and resumes in under 2 seconds when the reviewer comes back. While suspended: $0 compute.


Architecture

architecture

Every pull request creates its own MicroVM, receives a unique preview URL, automatically suspends when idle, and is destroyed when the PR closes.


The Solution

Project Structure

dynamic-pr-environment/
├── microvm-image/
│   ├── app.py              # Flask app + lifecycle hooks
│   ├── Dockerfile          # Based on al2023-minimal
│   └── requirements.txt    # flask, gunicorn, boto3
├── proxy/
│   └── handler.py          # Auth proxy Lambda (token validation + JWE forwarding)
├── infra/
│   ├── main.tf             # S3, DynamoDB, OIDC, IAM roles
│   ├── proxy.tf            # Proxy Lambda + Function URL + permissions
│   ├── outputs.tf
│   └── variables.tf
└── .github/workflows/
    ├── pr-deploy.yml        # Deploy on PR open/push
    └── pr-cleanup.yml       # Cleanup on PR close
Enter fullscreen mode Exit fullscreen mode

Every pull request creates its own MicroVM, receives a unique preview URL, automatically suspends when idle, and is destroyed when the PR closes.

The App

The demo application is a full-stack task manager. A Flask web app serving HTML on the frontend with a REST API (POST/DELETE) that persists tasks to DynamoDB.

Each PR gets its own DynamoDB partition (PR#), so reviewers see only that PR's data. Reviewers can add and delete tasks directly in the preview environment. Once the PR is closed, all DynamoDB data for that partition is automatically cleaned up

App code

Two Flask servers in one process:

  • Port 9000 (hooks server, started first in background thread): Handles /ready, /run, /suspend, /resume, /terminate hooks
  • Port 8080 (app server, main thread): Serves the actual web app
# First Hooks server starts FIRST (Lambda sends /ready here during image build)
hooks_thread = threading.Thread(target=start_hooks_server, daemon=True)
hooks_thread.start()

# Second Then app server
app.run(host="0.0.0.0", port=8080, debug=False)
Enter fullscreen mode Exit fullscreen mode

Why Lifecycle Hooks?

Lambda MicroVMs are snapshot-based. The image is frozen during build and restored whenever a MicroVM starts. Hooks let you customize each lifecycle stage.

Role of hooks in this solution:

  • /ready (during image build) - Flask app starts, responds 200, Lambda snapshots it. Build fails without this.
  • /run (MicroVM starts) - Receives PR number + branch + author, writes config to disk. One image, every PR.
  • /suspend (5 min idle, no reviewer) - MicroVM pauses. No reviewer = no cost.
  • /resume (reviewer comes back) - Reload config from disk. App resumes in under 2s.
  • /terminate (PR closed) - Delete all tasks in PR# from DynamoDB. Zero leftovers.

Without hooks, no way to configure a generic image per-PR at runtime or clean up data on shutdown.

@hooks_app.route("/aws/lambda-microvms/runtime/v1/run", methods=["POST"])
def hook_run():
    body = request.json or {}
    payload = json.loads(body.get("runHookPayload", "{}"))

    config = {
        "pr_number": payload.get("pr_number"),
        "branch": payload.get("branch"),
        "dynamodb_table": payload.get("dynamodb_table"),
    }

    with open(CONFIG_PATH, "w") as f:
        json.dump(config, f)

    return jsonify({"status": "ready"}), 200
Enter fullscreen mode Exit fullscreen mode

The Auth Proxy (proxy/handler.py)

A proxy Lambda behind a Function URL. Validates the access token, generates a short-lived JWE auth token, and forwards the request to the MicroVM.

# 1. Validate access token from DynamoDB
result = table.get_item(Key={"PK": f"TOKEN#{microvm_id}", "SK": "ACCESS"})
if token != result["Item"]["token"]:
    return response(403, "text/plain", "Access denied")

# 2. Generate JWE auth token for MicroVM
token_resp = lambda_client.create_microvm_auth_token(
    microvmIdentifier=microvm_id,
    expirationInMinutes=5,
    allowedPorts=[{"port": 8080}],
)

# 3. Forward request with auth
req = urllib.request.Request(target_url, headers={"X-aws-proxy-auth": token})
Enter fullscreen mode Exit fullscreen mode

For the demo, each PR gets a random access token stored in DynamoDB.

For production, replace this with GitHub OAuth or another identity provider to verify reviewers.

Note: For the demo i am using Lambda Function URL for simplicity but you can use API Gateway + Lambda Authorizer or CloudFront + Lambda@Edge if you need custom domains, rate limiting.

The GHA Workflow

.github/workflows/pr-deploy.yml is Triggered on pull_request: [opened, synchronize] with path filter on Blog/dynamic-pr-environment/**.

Deployment Workflow:

  1. OIDC authenticate to AWS (no long-lived secrets)
  2. Zip and upload code to S3
  3. update-microvm-image (triggers new snapshot build)
  4. Wait for UPDATED state
  5. Terminate old MicroVM
  6. run-microvm with runHookPayload containing PR metadata
  7. Generate access token, store in DynamoDB
  8. Post preview URL with token to PR comment

.github/workflows/pr-cleanup.yml does the cleanup.

Terraform code

Terraform creates

  • S3 bucket (code artifacts)
  • DynamoDB table (pr-environments, PAY_PER_REQUEST, partition key PK, sort key SK)
  • GitHub OIDC provider + IAM role
  • MicroVM build role + execution role
  • Auth proxy Lambda + Function URL

How to Test This

Prerequisites

  • Understanding of Lambda MicroVMs, covered in my previous blog
  • AWS account with Lambda MicroVMs access
  • Terraform >= 1.9
  • GitHub repo with Actions enabled

Step 1: Deploy Infrastructure

Edit terraform.tfvars with your values for github org and repo

cd infra
cp terraform.tfvars.example terraform.tfvars
terraform init && terraform apply
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure GitHub Secrets

From Terraform outputs, set these in your repo's Settings > Secrets:

Secret Value
AWS_ROLE_ARN GitHub Actions IAM role ARN
S3_BUCKET Artifacts bucket name
MICROVM_BUILD_ROLE_ARN Build role ARN
MICROVM_EXECUTION_ROLE_ARN Execution role ARN
PROXY_URL Auth proxy Function URL

Step 3: Create a PR

changes for PR

Make any change to a file under Blog/dynamic-pr-environment/ and open a PR. The workflow will:

  1. Build the MicroVM image (~2-3 min first time, faster after)

GHA

  1. Run the MicroVM
  2. Post a comment with the preview URL

pr comment

Step 4: Click the Preview URL

You should see your app running with the PR banner showing the branch name and PR number. Any code changes you push will trigger a new deployment automatically.

dark mode

You can also raise parallel PR with different feature

parallel pr

green PR

You can also review them together as each microvm is isolated per PR

parallel PR

Step 5: Close the PR

The cleanup workflow terminates the MicroVM

GHA

deletes all DynamoDB data for that PR, and removes S3 artifacts.

dynamodb

Cost: Lambda MicroVMs vs Shared Staging vs EKS

Scenario

  • Region: us-east-1
  • Developers: 5
  • Pull Requests: 40/month
  • PR Lifetime: 2 days
  • Active Usage: 50 minutes per PR (3 review sessions)
  • Application: Flask (2 GB RAM, 1 vCPU baseline)

Note: t4g.small is the smallest Graviton instance with 2 GB RAM, matching the MicroVM's 2 GB memory baseline.

Per raw compute-hour, Lambda MicroVMs cost more than equivalent EC2. But with MicroVMs you're not managing instances, patching AMIs, configuring ALBs, debugging security groups, or upgrading K8s clusters. One API call to create, one to destroy.

The operational overhead of EC2/EKS staging is invisible on the invoice but real on your team's time.

Where the savings actually come from; pay for active minutes, not uptime

Here's the part that matters: a PR sits open for 2 days, but reviewers only interact with it for ~50 minutes total. Lambda MicroVMs suspend after 5 minutes idle and bill $0 while suspended, so you're paying for 33.3 active hours across the whole month (40 PRs x 50 min), not 730.

Monthly Cost Breakdown

All three solutions use the same app (Flask, 2GB RAM) with DynamoDB and S3. The difference is how the preview environment is hosted: MicroVM (suspend/resume, Function URL) vs EC2 (always-on, ALB) vs EKS (always-on, ALB, control plane).

Resource Lambda MicroVMs Shared Staging EKS
Compute $4.20 (33.3 active hrs) $12.26 (t4g.small 24/7) $12.26 (t4g.small 24/7)
Snapshot (storage + read/write) $2.63 - -
Load Balancer / Proxy ~$0.01 (Lambda + Function URL) $16.93 (ALB) $16.93 (ALB)
EBS Storage (20GB gp3) - $2.40 $2.40
EKS Control Plane - - $73.00
DynamoDB ~$0.01 ~$0.01 ~$0.01
S3 ~$0.05 ~$0.05 ~$0.05
Total $6.90 $31.65 $104.65
Savings vs MicroVM - 78% 93%

Note: These numbers are specific to this scenario. Your savings will vary based on active usage time, number of PRs, and suspend/resume frequency. The breakeven guidance below helps you estimate for your own workload.

When does this stop making sense? MicroVMs break even with shared staging at ~290 min active per PR (~5 hrs), and with EKS at ~1000 min (~16 hrs).If reviewers spend 5+ active hours per PR, always-on staging becomes cheaper. Typical PR reviews (30–60 minutes) stay well within the MicroVM sweet spot.

The takeaway: Lambda MicroVMs don't win because they're cheap compute, they're actually pricier per vCPU-hour than plain EC2. They win because the billing model matches the actual usage pattern of a PR preview: mostly idle, briefly active. Shared staging and EKS pay the same 24/7 whether anyone's looking or not. And you're not paying for an ALB or EBS volume that sits idle all weekend.

Production Tips for Lambda MicroVMs

  1. Hooks must run on a separate port: Lambda routes user traffic to port 8080 by default. Lifecycle hooks (/ready, /run, /suspend, /resume, /terminate) should run on a separate port such as 9000, otherwise the image build can fail.

  2. Treat deployments as immutable: Every new commit creates a new MicroVM image snapshot. Instead of updating a running MicroVM, terminate the old one and start a new instance from the latest snapshot.

  3. Avoid network calls in the /run hook: The hook has a strict timeout of 1–60 seconds. DynamoDB cold connections can exceed that, so initialize data lazily after the application starts serving requests.

  4. Use runHookPayload for per-PR configuration: Pass values such as the PR number, branch name, or environment settings when starting the MicroVM instead of baking them into the image.

  5. readyTimeoutInSeconds can cause build failures: Setting this too aggressively may cause image builds to fail before the application is ready. Omitting it uses Lambda's default timeout, which is usually sufficient.

  6. Default quotas are low: Lambda MicroVMs have conservative limits on concurrent MicroVMs. Small teams are unlikely to notice, but larger teams should request a quota increase.

  7. ARM only (Graviton): Lambda MicroVMs currently support only arm64. Most Python, Node.js, and Go applications work without changes, but workloads with x86-only binaries will need to be ported.

  8. Use relative URLs behind the proxy: If your application is served from a path such as /microvm-123/, use relative paths (fetch("tasks")) instead of absolute ones (fetch("/tasks")) so requests continue to work correctly.


From DevOps Perspective

In this blog we saw how Lambda MicroVMs turn preview environments into an on-demand resource, every PR gets an isolated environment, reviewers test changes independently, and compute costs drop to $0 while idle. Combined with lifecycle hooks, they provide a surprisingly practical foundation for production-grade preview environments.

Curious whether teams running Kubernetes-based preview environments today would actually switch, or if the 8-hour runtime cap on MicroVMs is a dealbreaker for long-lived PRs. What's blocking you?

I share such amazing AWS updates on DevOps, Kubernetes and GenAI daily over Linkedin, X. Follow me over there so that I can make your life more easy.

Top comments (0)