Art by Phu is a WooCommerce storefront selling print reproductions of an artist's work. It's built on the same shape as Kayaking Geek — WordPress packaged as a Lambda container image, SQLite instead of RDS, wp-content and uploads on EFS, no NAT Gateway — for the same reason: keep hosting cost close to zero for a low-traffic site. The wrinkle a pure content site never hits: checkout has to call Stripe's API and send an order confirmation email, and both genuinely need outbound internet. The WordPress Lambda, on purpose, doesn't have any.

Checkout split across two Lambdas that never call each other directly Customer browser order-pay page Payment Lambda outside the VPC Stripe API checkout session POST /create-checkout-session create session webhook: completed serves order-pay page POST /webhook → mark order paid (inbound call) WordPress Lambda — order REST API ✕ no outbound internet — inbound requests only
Payments live in a second Lambda outside the VPC, reachable by the browser and by Stripe's webhook. The WordPress Lambda never calls out — it only answers the inbound "mark this order paid" request, the same way it answers any other request.

Why not just add a NAT Gateway for checkout

A NAT Gateway would give the existing Lambda outbound access and solve this in the most direct way possible. It also costs roughly $32/month before any data transfer — for a boutique shop, that's paying an always-on fee, every month, so that a handful of monthly checkouts can make an API call. It would erase most of the reason to run this on Lambda at all.

The fix: a second Lambda, deliberately outside the VPC

Plain Lambda functions that aren't attached to a VPC get free, direct internet access by default — no NAT Gateway required at all. So payments live in their own small, separate Lambda function (payment-lambda/), entirely disconnected from the WordPress Lambda's VPC, with exactly two routes:

  • POST /create-checkout-session — called directly from the customer's browser, not server-to-server. A custom Stripe gateway plugin redirects to WooCommerce's standard order-pay page, and a small inline script on that page calls this endpoint client-side to create a Stripe Checkout Session and get back a redirect URL. It has to happen browser-side because the WordPress Lambda itself has no route out to make the call on the customer's behalf.
  • POST /webhook — Stripe calls this when a Checkout Session completes. It verifies the signature, then calls into WordPress's own REST API (an inbound request, which works fine regardless of the WordPress Lambda's own outbound restrictions) to mark the order paid.

Same trick as Kayaking Geek's Garmin sync: when a function can't make outbound calls, the outbound leg moves to something else, and the result comes back as an inbound call the original function can always answer. Here that "something else" is a second Lambda instead of a GitHub Actions workflow, but it's the same underlying move.

There's no Stripe SDK in that Lambda — two endpoints doing signature verification and one API call are simple enough over plain fetch/crypto that pulling in the SDK, and the packaging complexity that comes with it, isn't worth it.

Keys live in wp-admin, not in Lambda environment variables

The Stripe secret key and webhook signing secret are entered under WooCommerce → Settings → Payments → Card, the same place any WooCommerce site would put them — not as environment variables on the payment Lambda. That Lambda fetches them per request from a small WordPress REST route instead, so rotating a key is a wp-admin edit, not a CloudFormation redeploy. Both directions — the config fetch and the mark-paid callback — are authenticated with a shared secret checked via hash_equals(); the mark-paid call also checks the order's own order key. It's fixed server-to-server plumbing, not a user-facing integration, so that's deliberately simpler than OAuth or WordPress application passwords.

The other surprise: WooCommerce's default order storage doesn't like SQLite

WordPress core, WooCommerce activation, and product CRUD all ran cleanly on SQLite from the start. WooCommerce's newer order storage (HPOS) didn't: the moment an order's status changed, it threw a real SQL error, because its refund-lookup query uses a MySQL-only unbounded LIMIT idiom the SQLite translator can't parse. That fires on essentially every real order, through the built-in new-order email.

The fix is a mu-plugin that seeds woocommerce_custom_orders_table_enabled to no via add_option() on muplugins_loaded — before WooCommerce's own plugin file is even required, since mu-plugins always load first. Order matters more than it sounds here: flipping that same option after WooCommerce has already defaulted it on triggers a "safe to switch storage backends" check that logs through WC_Logger — and that logging path itself fatals in this environment, because WP_Filesystem falls back to an FTP method that isn't actually available. Seeding the option first means WooCommerce never sees a transition to guard against, and the whole failure mode never triggers.

Where this generalizes

"This function can't reach the internet, but one specific feature needs it to" is a narrower version of the same problem the base hosting pattern is built around. The fix is almost never loosening the network boundary for everything — it's isolating the one thing that actually needs to reach out, giving that its own narrow path, and having it report back the normal way. If a serverless or cost-conscious setup you're running into a similar wall on, tell us what it needs to call and we can help figure out whether it needs a NAT Gateway or just a better-drawn boundary.

Joseph Rounds

Founder, Lighthouse Consulting

25+ years building enterprise software at McKesson (Fortune 10), Doctor On Demand, and IntelyCare. Now helping Boston-area businesses design and build custom software, AWS infrastructure, and AI integrations that fit how they actually operate.