Flipping x86_64 Lambdas to “save money on Graviton” fails when node-gyp artifacts from Ubuntu-x64 CI get uploaded to arm64 functions. The unfair advantage is a CI matrix that rebuilds native modules for each target, verifies with file/readelf, and refuses to publish a mismatched zip.
⚡ TL;DR: Build per architecture (
x86_64,arm64) with matching Lambda container images ornpm rebuild --arch. Set function Architectures explicitly in IaC. Fail the pipeline if a.nodebinary ELF machine type does not match. Prefer pure-JS deps when possible. Align runtime choices with Lambda Warm Pools and keep secrets out of builds via Secret-Aware Context Filters.
Matrix that rebuilds natives correctly
# .github/workflows/lambda-multiarch.yml
strategy:
matrix:
arch: [x86_64, arm64]
include:
- arch: x86_64
platform: linux/amd64
lambda_arch: x86_64
- arch: arm64
platform: linux/arm64
lambda_arch: arm64
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Build inside Lambda Node 20 image
run: |
docker run --rm --platform=${{ matrix.platform }} \
-v "$PWD":/var/task -w /var/task \
public.ecr.aws/lambda/nodejs:20 \
bash -lc "npm ci && npm run build && npm prune --omit=dev"
- name: Assert ELF arch
run: ./scripts/assert-native-arch.sh ${{ matrix.lambda_arch }}
- run: (cd dist && zip -r ../fn-${{ matrix.lambda_arch }}.zip .)
# scripts/assert-native-arch.sh
#!/usr/bin/env bash
set -euo pipefail
want="$1" # x86_64 | arm64
while IFS= read -r -d "" f; do
info=$(file "$f")
case "$want" in
arm64) echo "$info" | grep -q "ARM aarch64" ;;
x86_64) echo "$info" | grep -q "x86-64" ;;
esac || { echo "arch mismatch: $f ($info)"; exit 1; }
done < <(find node_modules dist -name "*.node" -print0 2>/dev/null)
✅ Build inside the Lambda base image for that arch.
❌ npm ci on GitHub’s x64 runner then deploy to arm64.
IaC must pin Architectures
new lambda.Function(this, "Api", {
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
code: lambda.Code.fromAsset(`artifacts/fn-arm64.zip`),
handler: "index.handler",
});
// BAD: default arch in one stack, zip built for another
new lambda.Function(this, "Api", {
runtime: lambda.Runtime.NODEJS_20_X,
code: lambda.Code.fromAsset("fn.zip"), // mystery arch
handler: "index.handler",
});
Dual-arch publish (two functions or two versions) only if you need gradual migration — do not ship a fat zip with both and hope.
Native module escape hatches
| Dep type | Strategy |
|---|---|
| Pure JS | Prefer; simplest |
Prebuilt *-linux-arm64 wheels/binaries |
Pin optionalDependencies carefully |
| Custom C++ addons | Rebuild in Lambda container per arch |
| Sharp / bcrypt / better-sqlite3 | Always matrix-test both arches |
Closing checklist
- [ ] CI matrix builds x86_64 and/or arm64 in Lambda base images
- [ ]
assert-native-archgate on*.nodeELF types - [ ] Function
Architecturesset in IaC to match artifact - [ ] Smoke invoke on the target arch before prod promote
- [ ] Document which native deps force matrix complexity
- [ ] Cost note: Graviton savings vs CI minutes tracked
Related reading
- Lambda Warm Pools: Low-Latency Backends for Coding Agent Tools
- Secret-Aware Context Filters: Stop AI Editors From Shipping Keys
- Cross-Account Bedrock Access: Platform Teams Without Shared Keys
- CI Failure Triage Bots: Separate Flaky Noise From Real Regressions
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
