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