← Back to posts

How We Fixed Turbo Remote Cache 502s by Bypassing a Turborepo Client Bug

· 6 min read

The Problem

This is a sequel to Ditching Nx for Turborepo. If you haven’t read that one, the short version: we migrated our monorepo from Nx to Turborepo and deployed a self-hosted remote cache on Lambda. That cache had a problem.

Our CI pipeline was taking 30+ minutes from merge to production. The single biggest bottleneck: CDK synth running from scratch on every CI stage because the Turbo remote cache silently failed on large artifacts.

The symptom was a non-fatal warning:

WARNING  failed to contact remote cache: HTTP status server error (502 Bad Gateway)

Turbo proceeded without caching, so CI still worked - just slowly. CDK synth ran 3 times per merge (PR checks, staging, production) at 4-5 minutes each. That’s 13 minutes of wasted compute on every single merge.

Root Cause: Lambda Function URL 6MB Limit

Our Turbo remote cache ran the turborepo-remote-cache package on a Lambda behind a Function URL. Lambda Function URLs have a hard 6MB request payload limit in buffered mode. Our CDK synth outputs (CloudFormation templates + bundled Lambda assets) compressed to 189MB for one app and 626MB for another. That’s not normal for CDK output. The bloat came from bundling Lambda assets inside the synth output rather than packaging them separately, and we later fixed this with a CDK synth/deploy split (a topic for its own post). But at the time, these were the artifact sizes we had to deal with.

The 502 occurred at the Function URL layer before Lambda was even invoked. The Lambda itself was perfectly healthy: zero errors, zero throttles, all invocations completing in 100-700ms.

The Obvious Fix That Didn’t Work: S3 Presigned URLs

The natural solution: don’t proxy artifact data through Lambda. Generate S3 presigned URLs and let Turbo upload directly to S3.

Turborepo supports this via its --preflight flag. The flow:

  1. Turbo sends OPTIONS to the cache server
  2. Server returns a presigned S3 URL in the Location header
  3. Turbo follows the URL and uploads directly to S3

We implemented this. It failed with SignatureDoesNotMatch.

After hours of debugging (AWS SDK checksum injection, query parameter forwarding, different presigning strategies), we traced the issue to Turborepo’s source code.

The Turborepo Client Bug

In crates/turborepo-api-client/src/lib.rs, the put_artifact and get_artifact functions:

1. request_url = preflight_response.location   // presigned S3 URL
2. request_builder = api_request(method, request_url)
3. request_builder = add_team_params(request_builder, team_id, team_slug)  // APPENDS ?slug=team

add_team_params() is called unconditionally - even after the URL has been replaced with a presigned S3 URL. It appends slug=team to the presigned URL. S3 includes all query parameters in signature verification. The appended parameter invalidates the signature.

This is a confirmed client-side bug. No issue has been filed (as of May 2026). It affects all presigned URL implementations when preflight is enabled.

This also means existing open-source solutions (EloB/turborepo-remote-cache-lambda, gpdenny/turborepo-s3-cache) likely don’t work with current Turbo versions despite appearing functional in their READMEs.

The Solution: CloudFront as an Auth Gateway

Since we can’t use S3 presigned URLs (Turbo breaks them) and we can’t proxy through Lambda (6MB limit), we needed a third path: CloudFront + CloudFront Function + Origin Access Control.

The architecture:

Turbo -> OPTIONS -> Lambda (preflight)
                    |
         Returns HMAC-signed CloudFront URL
                    |
Turbo -> PUT -> CloudFront
               |
         CloudFront Function:
           - Validates HMAC token
           - Strips ALL query params (including Turbo's slug)
           - Forwards clean request to S3
               |
         S3 via Origin Access Control

The key insight: CloudFront Functions can strip query parameters before the request reaches S3. Turbo can append whatever it wants - the CloudFront Function removes it all. S3 never sees the extra params.

How It Works

Lambda (preflight only):

  • Receives OPTIONS from Turbo
  • Validates Bearer token
  • Generates an HMAC: SHA256(secret, "{method}:{path}:{expires}")
  • Returns a CloudFront URL with the HMAC token as a query param

CloudFront Function (viewer-request, ~50 lines ES5):

  • Validates the HMAC token
  • Checks expiry
  • Verifies HTTP method matches signed method
  • Strips ALL query parameters
  • Forwards clean request to S3

CloudFront Distribution:

  • S3 origin with Origin Access Control (supports GET, PUT, DELETE)
  • Caching disabled (pass-through to S3)
  • No payload size limit (CloudFront handles up to 64GB)

Lessons Learned Along the Way

CloudFront Function Gotchas

Three things we hit that aren’t obvious from the docs:

  1. request.method is read-only. It’s the only read-only field on the request object. Attempting to set it returns FunctionValidationError (503). We originally tried to set the method from our signed param - instead we validate it matches.
  2. Response body uses data, not value. The response body format is { encoding: "text", data: "..." }. Using value instead of data causes FunctionValidationError with the unhelpful message “body data is missing.”
  3. Fn::Sub uses ${VarName} syntax. We injected the HMAC secret into the function source at deploy time via CloudFormation Fn::Sub. The placeholder must be ${__HMAC_SECRET__}, not __HMAC_SECRET__. Without the ${}, CloudFormation passes the string through unchanged.

Cost

The entire CloudFront setup adds ~$0.50/month. CloudFront Function invocations are free (first 10M/month). Data transfer for CI cache traffic is negligible.

Results

MetricBeforeAfter
CDK synth (6 domains)4-5 minutes4 seconds
Build backend job4-5 minutes57 seconds
502 warningsEvery runZero
Merge-to-production pipeline~35 minutes~25 minutes

The CDK synth cache hit prints >>> FULL TURBO - all 6 domains replayed from cache in under 4 seconds total.

Should You File the Turbo Bug?

Yes. The add_team_params issue is straightforward to fix upstream: skip the call when preflight was used (the team context was already baked into the preflight request). Until it’s fixed, the CloudFront gateway pattern works as a robust workaround.

Tech Stack

  • AWS CDK 2.x (TypeScript)
  • CloudFront + CloudFront Function (JS 2.0 runtime)
  • S3 + Origin Access Control
  • Lambda Function URL (preflight only)
  • Turborepo 2.9.x with TURBO_PREFLIGHT=true