Turborepo Remote Cache Self-Hosted — Cutting CI Build Time from 6 Minutes to 45 Seconds with AWS S3 + GitHub Actions, No Vercel Required
Have you ever had CI running for 6 minutes every time you open a pull request? There were moments where I'd change a single button component in packages/ui, watch the entire app rebuild, and think "...is this really right?" Turborepo's Remote Cache was built to solve exactly this problem — team member A's already-built results can be reused directly by team member B.
The catch is that using the official remote cache requires a Vercel account. Vercel currently offers free remote caching for personal accounts, but for team use or to remove cache size and speed limits, a paid plan is required (latest Vercel pricing). Teams that want to keep cache data on their own infrastructure, or who don't want to be locked into the Vercel ecosystem, will naturally think about "building it themselves." I was worried this would be fairly complex at first, but it turned out to be much simpler than I expected.
This post covers building a self-hosted cache server backed by AWS S3 using ducktors/turborepo-remote-cache and integrating it with GitHub Actions. Real-world cases have reported CI build times dropping from 6 minutes to 45 seconds. We'll start by spinning up a server in 5 minutes with Docker, then naturally move on to Lambda serverless deployment, MinIO on-premises configuration, and the security settings that trip people up most often in production.
This post assumes readers have basic experience with Docker and AWS S3. Familiarity with GitHub Actions configuration will make it easier to follow along.
Core Concepts
How Does Turborepo Remote Cache Work
Before executing a task, Turborepo hashes the task's inputs (source code + environment variables). It sends that hash to the cache server — if a result is already stored, it skips the work; if not, it runs the task and uploads the result.
[Turborepo Client]
│
├── GET /v8/artifacts/{hash} → Cache hit: download artifact and reuse
│ → Cache miss: execute task
│
└── PUT /v8/artifacts/{hash} → Upload result after task completesThe key point is that Turborepo uses a public API spec. Even if it's not Vercel's server, any server that implements this API spec can be plugged in. ducktors/turborepo-remote-cache is an open-source project that fully implements this spec based on Node.js (Fastify).
Turborepo Remote Cache API: A cache protocol published by Vercel, consisting of an HTTP API spec that retrieves cache via
GET /v8/artifacts/{hash}and stores cache viaPUT /v8/artifacts/{hash}. Any server implementing this spec can integrate with the official Turborepo client.
When Cache Breaks — Debugging Unexpected Cache Misses
When operating your own cache server, you'll inevitably hit a moment of "I didn't touch any code, why isn't the cache matching?" The elements Turborepo includes when computing a cache hash are:
- Source file contents of the package
- Dependency versions in
package.json - Environment variable values specified in the
envfield ofturbo.json - Whether upstream packages in the dependency graph have changed
In other words, even if you haven't touched the code, a cache miss will occur if variables like NODE_ENV or deployment environment variables change. Conversely, if you omit a necessary environment variable from the env field, you risk incorrectly reusing the same cache across different environments. If the cache isn't hitting as expected, you can use the following command to inspect the list of inputs included in the hash computation:
pnpm turbo run build --dry=jsonThe Three Environment Variables Needed for Client Connection
To point the Turborepo client at an external cache server, you only need to set three environment variables.
| Environment Variable | Role | Example Value |
|---|---|---|
TURBO_API |
Cache server URL | https://cache.example.com |
TURBO_TOKEN |
Bearer auth token | my-secret-token-abc123 |
TURBO_TEAM |
Team identifier (slug) | my-team |
Vercel-specific commands like turbo login or turbo link are not used. You connect by setting environment variables directly or by modifying .turbo/config.json.
// .turbo/config.json (useful for local development, safe to git commit)
{
"teamId": "my-team",
"apiUrl": "http://localhost:3000"
}Do not include the token (
TURBO_TOKEN) in.turbo/config.json. Set it as an environment variable separately or register it as a CI secret. Theconfig.jsonfile itself contains no sensitive information, so it's safe to commit to git.
Practical Implementation
Spinning Up the Cache Server Quickly with Docker Compose
This is the lowest-barrier method when first giving it a try. ducktors/turborepo-remote-cache provides an official Docker image, so you can bring up the server with a single docker-compose.yml. I initially tried to configure S3 all at once and things got tangled, so I confirmed it was working with STORAGE_PROVIDER: local first and then switched to S3 — that approach was much smoother.
# docker-compose.yml
version: '3.8'
services:
turborepo-cache:
image: ducktors/turborepo-remote-cache:latest
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: 3000
TURBO_TOKEN: ${TURBO_TOKEN} # A random string of at least 20 characters is recommended
STORAGE_PROVIDER: s3
STORAGE_PATH: my-turborepo-cache-bucket
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_REGION: ap-northeast-2
restart: unless-stopped| Environment Variable | Description |
|---|---|
STORAGE_PROVIDER |
Storage backend selection. One of s3, gcs, azure-blob, or local |
STORAGE_PATH |
S3 bucket name or local path |
TURBO_TOKEN |
An arbitrary secret token for client authentication |
AWS_REGION |
The region where the S3 bucket is located |
Once the server is up, you can verify it's working via the health check endpoint. For initial local testing, make requests to localhost:3000, then switch to your production URL — that's the recommended flow.
# When first testing locally
curl http://localhost:3000/v8/artifacts/status \
-H "Authorization: Bearer ${TURBO_TOKEN}"
# {"status":"enabled"}
# After deploying to production server
curl https://cache.example.com/v8/artifacts/status \
-H "Authorization: Bearer ${TURBO_TOKEN}"
# {"status":"enabled"}The S3 bucket must be created with public access blocked, and IAM users should be granted only the minimum permissions needed for that bucket.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::my-turborepo-cache-bucket/*"
}
]
}Integrating with GitHub Actions
Once the server is ready, add the client configuration to your GitHub Actions workflow. It's simpler than you might think — just add three environment variables.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
env:
TURBO_API: ${{ secrets.TURBO_API }} # Cache server URL
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} # The token configured above
TURBO_TEAM: my-team # Team slug
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build test lint
# First run: cache miss → execute then upload
# Subsequent runs: cache hit → skip (completes in seconds)Register TURBO_API and TURBO_TOKEN under your GitHub repository's Settings → Secrets and variables → Actions, and the entire team will share the same cache.
Reducing Operational Overhead with Lambda Serverless
When this approach is the right fit: Ideal for small teams or side projects where maintaining a dedicated server 24/7 is burdensome and request frequency is low. It's cost-effective because no charges are incurred when there are no requests.
ducktors/turborepo-remote-cache supports wrapping a Fastify app as an AWS Lambda handler. The core configuration is as follows:
// lambda.js
const awsLambdaFastify = require('@fastify/aws-lambda')
const { createApp } = require('turborepo-remote-cache')
const app = createApp({
storageProvider: 's3',
storagePath: process.env.STORAGE_PATH,
awsRegion: process.env.AWS_REGION,
})
const proxy = awsLambdaFastify(app)
exports.handler = async (event, context) => {
await app.ready()
return proxy(event, context)
}Lambda functions can use IAM role-based authentication, enabling S3 access without AWS_ACCESS_KEY_ID. Simply grant the IAM role attached to the function s3:GetObject, s3:PutObject, and s3:DeleteObject permissions.
S3 bucket creation and cache expiration rules apply the same way regardless of which deployment method you choose.
# Create S3 bucket (public access blocking is mandatory)
aws s3api create-bucket \
--bucket my-turborepo-cache \
--region ap-northeast-2 \
--create-bucket-configuration LocationConstraint=ap-northeast-2
# Apply a Lifecycle rule to auto-delete after 30 days (cost savings)
aws s3api put-bucket-lifecycle-configuration \
--bucket my-turborepo-cache \
--lifecycle-configuration '{
"Rules": [{
"ID": "expire-cache",
"Status": "Enabled",
"Filter": {"Prefix": ""},
"Expiration": {"Days": 30}
}]
}'For Lambda deployment tools (Serverless Framework, AWS SAM, CDK), choose whichever fits your team's existing infrastructure. Detailed configuration examples can be found in the official Lambda deployment guide.
Fully On-Premises Configuration with MinIO
When this approach is the right fit: Suitable for environments that must operate solely on their own servers without an AWS account, or where security requirements prohibit any data from leaving for external cloud services. Also useful for spinning up the full stack locally during development without AWS.
# docker-compose.yml (MinIO integrated configuration)
version: '3.8'
services:
minio:
image: minio/minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minio-admin
MINIO_ROOT_PASSWORD: change-this-before-use # Example value — must be changed in production
volumes:
- minio-data:/data
turborepo-cache:
image: ducktors/turborepo-remote-cache:latest
ports:
- "3000:3000"
environment:
STORAGE_PROVIDER: s3
STORAGE_PATH: turbo-cache
S3_ENDPOINT: http://minio:9000 # Specify MinIO endpoint
AWS_ACCESS_KEY_ID: minio-admin
AWS_SECRET_ACCESS_KEY: change-this-before-use # Example value — must be changed in production
AWS_REGION: us-east-1 # MinIO accepts any arbitrary region value
TURBO_TOKEN: ${TURBO_TOKEN}
depends_on:
- minio
volumes:
minio-data:MinIO: An open-source object storage that implements the AWS S3 API exactly. By specifying the endpoint via the
S3_ENDPOINTenvironment variable, you can useSTORAGE_PROVIDER: s3as-is. DigitalOcean Spaces can be integrated the same way.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Vendor independence | Operates without a Vercel account or plan. No platform lock-in |
| Data sovereignty | Cache artifacts are stored in your own S3 bucket. Nothing leaves |
| Multiple storage backends | S3, GCS, Azure Blob, MinIO, DO Spaces, local filesystem |
| Fast setup | Ready for production immediately with a single Docker image |
| Reduced CI time | Eliminates duplicate builds by sharing cache across teams and pipelines. Cases of 6 min → 45 sec reported |
| Library embed | Provided as an npm package, integrable into existing Node.js servers |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Operational overhead | Server hosting, monitoring, and updates must be managed manually | Use Lambda serverless deployment to minimize management burden |
| Accumulating cache costs | Old cache objects pile up and increase storage costs | Set S3 Lifecycle rule to auto-delete after 30–60 days |
| Security configuration | Token management, bucket public access blocking, risk of sensitive data in cache | Block S3 bucket public access + explicitly manage env field in turbo.json |
| Diminishing returns with large changes | More changed packages means more cache misses and less benefit | Use alongside architecture that enforces clear package boundaries |
| Ephemeral environment limitation | CI runner local cache cannot be reused; remote cache server is mandatory | Apply remote cache server URL uniformly across all pipelines |
Ephemeral CI Environment: A setup where a new container is created for each CI run and deleted after completion. Since local file cache doesn't persist to the next run, sharing cache across teams must go through a remote server.
The Most Common Mistakes in Practice
Honestly, I initially glossed over these mistakes myself and came back to fix them later.
-
Leaving the S3 bucket with public access open: At first I thought "we have a token anyway, so it's fine to leave the bucket public," and then got called out by the team. Even if the cache server itself is exposed to the public, the backend S3 bucket must have public access blocked and be accessible only via IAM roles or access keys. Build artifacts can contain snapshots of source code.
-
Omitting the
envfield inturbo.json: Turborepo caches build logs as artifacts too. If you don't specify which environment variables should be included in the cache hash via theenvfield, sensitive information like database passwords can end up mixed into the cache through logs. It's recommended to explicitly manage the environment variables that should affect the cache hash, like so:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
}
}
}- Putting off the S3 Lifecycle rule configuration: At setup time you think "I'll do it later," but a few months down the line you'll come back after seeing the S3 bill. It's strongly recommended to set a 30–60 day expiration rule alongside bucket creation, using the CLI example in the Lambda section above.
Closing Thoughts
The ducktors/turborepo-remote-cache + S3 combination is a practical choice for sharing build cache across your entire team without Vercel. Cases like Mercari's show large teams cutting CI job times by 30–50%, but even small monorepos with just two or three packages can noticeably reduce repeated build costs. If operational overhead is a concern, start with the Lambda approach; if data cannot leave your environment, there's the MinIO combination. The right option for your situation is already available.
Three steps to get started right now:
-
You can verify it works locally first. Run a local filesystem-based cache server immediately with
npx turborepo-remote-cache, paste the token printed to the terminal intoTURBO_TOKEN, setTURBO_API=http://localhost:3000, and confirm that cache hits are occurring. -
It's recommended to prepare an S3 bucket and IAM permissions. When creating a new bucket, set public access blocking as the default and apply a 30-day Lifecycle expiration rule at the same time — this reduces the need to come back and fix things later. Granting the IAM user only
s3:GetObject,s3:PutObject, ands3:DeleteObjectpermissions on that bucket is sufficient. -
You can register
TURBO_APIandTURBO_TOKENas GitHub Actions secrets and run the workflow once. When you start seeingFULL TURBO(cache hit) messages in the Turbo output log, that's the signal that the cache is working correctly.
References
- ducktors/turborepo-remote-cache | GitHub
- Turborepo Remote Cache Official Docs | ducktors
- Supported Storage Providers | ducktors
- Custom Remote Caching Setup Guide | ducktors
- Running in AWS Lambda | ducktors
- ducktors/turborepo-remote-cache | Docker Hub
- Remote Caching Official Docs | Turborepo
- Setting Up Turborepo Remote Cache with S3 and GitHub Actions | januschung
- Accelerating CI with Turborepo Remote Cache | Mercari Engineering
- Alternative remote caching hosts | Turborepo Community
- trappar/turborepo-remote-cache-gh-action | GitHub
- Optimizing CI/CD with Turborepo Remote Caching | Leapcell