← All writing

Running a containerized web app on AWS Lambda

How md2docx went from a VPS container to serverless on AWS for ~$0 — without changing a line of app code, plus the three sharp edges nobody warns you about.

  • AWS
  • Serverless
  • Lambda

md2docx ran happily as a Docker container on a VPS. I moved it to serverless on AWS for two reasons: pay ~$0 while nobody’s using it, and learn the platform properly. The surprise was that the application code didn’t change at all. The friction was everywhere else.

The app doesn’t change — an adapter does

Lambda speaks events; Express speaks HTTP. The AWS Lambda Web Adapter bridges the two: drop its binary into the container image as a Lambda extension, and it translates each invoke into a plain HTTP request to your server on localhost. The Dockerfile gains one line; the server stays exactly as it runs on a laptop. I built the image for arm64 (Graviton — about 20% cheaper), pushed it to ECR, and let Lambda serve it behind a built-in Function URL.

Three sharp edges

Then the platform pushed back.

  • The region didn’t support Function URLs. Creating one failed with AccessDeniedException: Unable to determine service/operation name to be authorized — which screams “permissions bug” but actually means “feature not available in this region.” A newer region simply didn’t have it. Fix: use a tier-1 region.
  • New AWS accounts block public Function URLs by default. Even with a textbook-correct resource policy granting lambda:InvokeFunctionUrl to everyone, every request returned 403 Forbidden. The undocumented-feeling fix: also grant lambda:InvokeFunction to *.
  • CloudFront in front, but OAC won’t sign POST bodies. To force traffic through the CDN I tried Origin Access Control with IAM auth — and POST broke instantly, because OAC doesn’t include the request body in its SigV4 signature and Lambda rejects unsigned payloads. For an app whose whole job is POSTing Markdown, that’s fatal. I switched to a shared secret: CloudFront injects a header, and the app rejects anything without it.

What it costs

Lambda and CloudFront both have perpetual free tiers — not the six-month credits — of 1M requests + 400k GB-seconds and 1 TB of egress per month. A hobby converter lives inside them with room to spare, so the bill is effectively $0, and it scales to zero when idle. The price you pay is latency: the first request after a quiet spell waits a few seconds for a cold start. For a tool people hit occasionally, that’s a fine trade.

Serverless didn’t make the app simpler — it made it free at rest and someone else’s problem to keep running. The catch was never the code; it was the dozen small defaults around it.