Vercel 없이 Turborepo Remote Cache 셀프 호스팅하는 방법 — Docker Compose + MinIO + GitHub Actions로 CI 빌드 시간 40~80% 단축
모노레포를 운영해본 분이라면 CI에서 pnpm turbo run build 한 줄이 5분, 10분씩 돌아가는 광경이 낯설지 않을 겁니다. 패키지가 10개만 넘어가도 변경한 건 딱 두 개인데 전체를 다시 빌드하는 상황이 반복되죠. 저도 처음엔 "Turborepo가 로컬에서는 캐시를 잘 쓰는데 왜 CI에서는 매번 처음부터 돌릴까" 하고 한참 고민했습니다. 답은 간단합니다. 로컬 캐시는 해당 머신에만 존재하기 때문에, CI 워커가 매번 새 컨테이너로 뜨면 캐시가 전혀 없는 상태에서 시작할 수밖에 없습니다.
Remote Cache를 제대로 붙이면 얘기가 달라집니다. Mercari Engineering이 2026년 초에 공개한 사례에 따르면, Turborepo Remote Cache 도입 후 CI 태스크 소요 시간이 약 50% 단축됐다고 합니다. 팀과 규모에 따라 4080% 범위에서 효과를 체감할 수 있으니, 10분짜리 빌드가 26분으로 줄어드는 셈입니다. 이 글에서는 Vercel 계정 없이 오픈소스 캐시 서버를 Docker Compose로 띄우고, MinIO를 스토리지로 쓰며, GitHub Actions에 연결하는 전 과정을 다룹니다. AWS S3로 전환하는 방법과 GitLab CI 연동도 함께 살펴봅니다.
이 글은 Turborepo 기반 모노레포를 이미 운영 중이고, Docker Compose와 GitHub Actions에 기본적으로 익숙한 분들을 대상으로 합니다. 이 글을 끝까지 읽으면 다음 PR부터 바로 적용할 수 있습니다.
핵심 개념
Remote Cache가 동작하는 방식
Turborepo는 각 태스크를 실행하기 전에 입력 해시를 계산합니다. 소스 파일, package.json, 환경변수, turbo.json의 파이프라인 설정까지 모두 합쳐서 하나의 고유한 해시값을 만들어냅니다. 그리고 Remote Cache 서버에 이 해시에 해당하는 아티팩트가 있는지 물어봅니다.
turbo run build 실행
↓
각 태스크의 입력 해시 계산 (소스 + 의존성 + 환경변수)
↓
GET /v8/artifacts/:hash → 캐시 서버
↓
캐시 히트 → 결과물 복원, 태스크 스킵
캐시 미스 → 태스크 실행 후 PUT /v8/artifacts/:hash 로 업로드여기서 "결과물 복원"이 구체적으로 무엇인지 궁금하실 텐데, dist/, .next/ 같은 빌드 아웃풋 폴더와 태스크 실행 로그를 함께 묶어서 아티팩트로 저장합니다. 캐시가 히트되면 터미널에도 이전에 실행했던 것과 똑같은 로그가 출력되는 이유가 여기에 있습니다.
내용 기반 해싱(content-addressed hashing): 파일 경로나 타임스탬프가 아닌 실제 파일 내용을 기준으로 해시를 만드는 방식입니다. 코드가 바뀌지 않았다면, 서로 다른 브랜치에서도 같은 해시가 나와 캐시를 공유할 수 있습니다.
프로토콜이 굉장히 단순합니다. GET과 PUT 두 엔드포인트만 구현하면 어떤 HTTP 서버도 Turborepo의 Remote Cache가 될 수 있습니다. Vercel이 이 스펙을 공개했고, 커뮤니티에서 다양한 구현체를 만들어냈습니다.
필수 환경변수 세 가지
Turborepo가 커스텀 캐시 서버를 인식하려면 세 가지 환경변수가 필요합니다.
| 변수 | 역할 | 예시 |
|---|---|---|
TURBO_API |
캐시 서버의 베이스 URL | https://cache.internal.mycompany.com |
TURBO_TEAM |
캐시 네임스페이스 분리용 팀 식별자 | my-team |
TURBO_TOKEN |
Bearer 인증 토큰 | 무작위 문자열 |
Bearer 인증: HTTP
Authorization헤더에Authorization: Bearer <token>형태로 토큰을 실어 보내는 방식입니다. 서버는 이 토큰이 등록된 값과 일치하는지 확인해 요청의 유효성을 검증합니다. Turborepo는 모든 캐시 API 요청에 이 방식을 사용합니다.
환경변수 방식 대신 .turbo/config.json에 URL과 팀 이름을 고정해둘 수도 있습니다. 토큰은 보안상 항상 환경변수로 분리하는 것을 권장합니다.
{
"apiUrl": "https://cache.internal.mycompany.com",
"teamId": "my-team"
}teamId라는 필드명이 환경변수 TURBO_TEAM과 달라서 처음엔 헷갈릴 수 있습니다. 둘 다 같은 값을 담는데, JSON 설정 파일은 camelCase 컨벤션을, 환경변수는 SCREAMING_SNAKE_CASE를 따르기 때문입니다. 어느 방식을 쓰든 동작은 동일합니다.
실전 적용
로컬 환경 구성: Docker Compose + MinIO
가장 먼저 로컬에서 캐시 서버를 띄워보는 것을 권장합니다. ducktors/turborepo-remote-cache는 Node.js(Fastify) 기반의 커뮤니티 표준 구현체로, Docker Hub에 공식 이미지가 올라와 있어 별도 빌드 없이 바로 쓸 수 있습니다.
MinIO: AWS S3 API와 완벽하게 호환되는 오픈소스 오브젝트 스토리지입니다. 사설 환경에서 S3와 동일한 방식으로 데이터를 저장·관리할 수 있어, 클라우드 의존 없이 S3 호환 인프라를 구성할 때 자주 활용됩니다.
# docker-compose.yml
services:
turborepo-cache:
image: ducktors/turborepo-remote-cache:latest
ports:
- "3000:3000"
environment:
- TURBO_TOKEN=my-secret-token # openssl rand -hex 32 으로 생성 권장
- STORAGE_PROVIDER=s3
- S3_ACCESS_KEY=minioadmin
- S3_SECRET_KEY=minioadmin
- S3_BUCKET=turborepo-cache
- S3_ENDPOINT=http://minio:9000
- S3_REGION=us-east-1
depends_on:
minio:
condition: service_healthy
minio:
image: minio/minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001" # MinIO 웹 콘솔
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
volumes:
minio_data:TURBO_TOKEN에는 아래 명령어로 생성한 랜덤 토큰을 쓰는 것을 권장합니다.
openssl rand -hex 32--console-address ":9001" 플래그를 빠트리면 MinIO 웹 콘솔(http://localhost:9001)에 접속이 되지 않습니다. 저도 처음에 이 플래그 없이 띄웠다가 콘솔이 왜 안 열리는지 한참 헤맸습니다.
healthcheck + condition: service_healthy 조합은 MinIO가 완전히 준비된 뒤에 캐시 서버가 뜨도록 보장합니다. 이 설정이 없으면 MinIO가 아직 초기화 중인 시점에 캐시 서버가 연결을 시도해서, 원인을 추적하기 어려운 캐시 미스 오류가 반복될 수 있습니다.
| 항목 | 역할 |
|---|---|
ducktors/turborepo-remote-cache |
Turborepo API 스펙을 구현한 캐시 서버 |
minio |
S3 호환 오브젝트 스토리지, 아티팩트 실제 저장소 |
TURBO_TOKEN |
Bearer 토큰, openssl rand -hex 32로 생성 권장 |
S3_ENDPOINT |
MinIO를 AWS S3 대신 쓰기 위한 커스텀 엔드포인트 |
서버가 뜨고 나면 http://localhost:3000/health에서 상태를 확인할 수 있습니다. 이후 MinIO 콘솔(http://localhost:9001)에서 turborepo-cache 버킷을 미리 생성해두면 됩니다.
프로덕션 전환: AWS S3로 스토리지 교체
로컬 MinIO로 검증을 마쳤다면, 프로덕션에서는 환경변수 몇 개만 바꾸면 AWS S3로 전환됩니다. 아래는 docker-compose.yml 위에 override로 얹는 파일로, docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d처럼 두 파일을 함께 지정해서 실행합니다.
# docker-compose.prod.yml
# docker-compose.yml 위에 얹는 override 파일입니다
# 실행: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services:
turborepo-cache:
environment:
- TURBO_TOKEN=${TURBO_TOKEN}
- STORAGE_PROVIDER=s3
- S3_ACCESS_KEY=${AWS_ACCESS_KEY_ID}
- S3_SECRET_KEY=${AWS_SECRET_ACCESS_KEY}
- S3_BUCKET=my-company-turborepo-cache
- S3_REGION=ap-northeast-2
# S3_ENDPOINT를 생략하면 AWS S3 기본값을 사용합니다S3 버킷에는 Lifecycle 정책을 함께 걸어두는 것을 권장합니다. 6개월쯤 지나 스토리지 청구서를 보고 나서야 뒤늦게 설정하는 분들이 많습니다. 처음 버킷 생성할 때 아래 설정을 같이 적용해두면 30일 이상 된 아티팩트가 자동으로 만료됩니다.
{
"Rules": [
{
"ID": "turborepo-cache-ttl",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"Expiration": { "Days": 30 }
}
]
}GitHub Actions 연동
캐시 서버가 준비됐다면 CI 연결은 환경변수 세 개면 끝납니다. 솔직히 이 부분은 생각보다 너무 간단해서 처음에 뭔가 빠트린 게 아닌가 의심이 들었을 정도입니다.
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
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
- name: Build with Remote Cache
run: pnpm turbo run build test lint
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}GitHub 레포 설정에서 아래와 같이 등록해두면 됩니다.
| 위치 | 키 | 값 |
|---|---|---|
| Settings → Secrets | TURBO_API |
https://cache.internal.mycompany.com |
| Settings → Secrets | TURBO_TOKEN |
캐시 서버에 설정한 토큰 |
| Settings → Variables | TURBO_TEAM |
my-team |
TURBO_TEAM을 Secrets 대신 Variables에 넣는 이유는, 팀 이름은 민감 정보가 아니라 Workflow 로그에서 보여도 무방하기 때문입니다.
GitLab CI 연동
GitLab을 쓰는 팀이라면 Settings → CI/CD → Variables에서 동일한 세 가지 변수를 등록하고, .gitlab-ci.yml에 아래처럼 주입해주면 됩니다.
# .gitlab-ci.yml
variables:
TURBO_API: "${TURBO_API}"
TURBO_TOKEN: "${TURBO_TOKEN}"
TURBO_TEAM: "${TURBO_TEAM}"
build:
stage: build
script:
- pnpm install
- pnpm turbo run build test lint
cache:
key: pnpm-$CI_COMMIT_REF_SLUG
paths:
- node_modules/.cache/pnpmcache 블록의 역할에 대해 한 가지 짚어두겠습니다. 여기 있는 cache는 GitLab 내장 캐시로, pnpm install이 빠르게 끝나도록 node_modules를 저장해두는 용도입니다. Turborepo Remote Cache와는 별개로, 둘이 각자 다른 레이어를 캐시합니다. GitLab 캐시는 패키지 설치 시간을, Turborepo Remote Cache는 빌드·테스트 실행 시간을 단축합니다. 함께 쓰면 시너지가 생깁니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 빌드 시간 단축 | 대형 모노레포 기준 CI 빌드 시간 40~80% 단축 가능. Mercari Engineering은 태스크 소요 시간 50%, CI 잡 전체 시간 30% 단축을 보고 |
| CI 비용 절감 | 러너 사용 시간이 줄어드니 GitHub Actions 유료 플랜 또는 자체 러너 비용도 함께 절감 |
| 팀 간 캐시 공유 | 동일 커밋 기준으로 여러 워커·개발자 로컬이 캐시를 공유. PR을 열기 전에 동료가 이미 빌드했다면 그 캐시를 내 CI가 그대로 사용 |
| 벤더 독립성 | Vercel 플랫폼, Nx Cloud 등 외부 SaaS에 의존하지 않고 인프라를 직접 통제 가능 |
| 단순한 API 스펙 | HTTP PUT/GET 두 엔드포인트가 전부. 직접 구현하거나 기존 오브젝트 스토리지에 얇은 레이어를 씌우는 것도 어렵지 않음 |
| 스토리지 선택 자유 | AWS S3, MinIO, GCS, DigitalOcean Spaces, 로컬 디스크 등 원하는 백엔드 선택 가능 |
단점 및 주의사항
이 선택지가 모든 팀에 맞는 건 아닙니다. 솔직하게 단점도 짚어봅니다.
- 인프라 운영 부담 — 캐시 서버의 가용성, 보안 패치, 업그레이드를 직접 관리해야 합니다. 팀에 DevOps 전담자가 없다면 이 선택지가 오히려 부담이 될 수 있습니다. Railway 원클릭 배포로 운영 부담을 줄이는 방법도 있습니다.
- 캐시 무효화 복잡성 — 잘못된 캐시 히트로 stale 결과물이 배포될 수 있습니다.
turbo.json의inputs필드를 명확히 지정하고 환경변수 목록을 점검하면 대부분 예방할 수 있습니다. - 스토리지 비용 누적 — 아티팩트가 쌓이면 S3 비용이 증가합니다. TTL/Lifecycle 정책으로 30일 이상 된 아티팩트를 자동 만료시키는 것을 권장합니다.
- 초기 콜드 캐시 — 처음 실행 시 캐시가 없어 업로드 오버헤드만 발생합니다. 첫 실행이 느린 것은 정상이며, 두 번째 실행부터 효과를 체감할 수 있습니다.
- 변경 범위 의존성 — 대규모 변경 PR에서는 캐시 히트율이 낮아 효과가 제한적입니다. 소규모 커밋 단위로 작업하는 문화와 시너지가 좋습니다.
turbo login/link미지원 — 커스텀 서버는turbo login,turbo link명령어를 사용할 수 없습니다..turbo/config.json과 환경변수로 수동 설정해야 합니다.
stale 캐시: 입력은 같아 보이지만 실제로는 달라진 경우를 감지하지 못해, 이전 실행의 낡은 결과물이 그대로 사용되는 상황입니다.
turbo.json의inputs배열에 빌드에 영향을 주는 파일 패턴을 정확히 명시하면 대부분 예방할 수 있습니다.
실무에서 가장 흔한 실수
TURBO_TOKEN을 코드에 하드코딩하는 것 —.env파일에 넣고.gitignore에 추가했더라도, CI 워크플로 파일에 직접 값을 쓰는 순간 커밋 히스토리에 남습니다. 항상 Secrets를 통해 주입하는 것을 권장합니다.- 캐시 서버를 HTTP로 퍼블릭 인터넷에 노출하는 것 — 토큰이 있어도 평문 HTTP는 스니핑에 취약합니다. 외부 접근이 필요하다면 TLS(HTTPS)를 반드시 앞단에 붙이는 것이 좋습니다. 내부망에서만 사용한다면 HTTP도 무방합니다.
- S3 버킷 Lifecycle 정책을 설정하지 않는 것 — 잊고 넘어가기 쉬운 부분인데, 6개월쯤 지나 스토리지 청구서를 보고 나서야 깨닫는 경우가 많습니다. 처음 버킷 생성할 때 30일 만료 정책을 같이 걸어두는 것을 권장합니다.
마치며
토큰은 코드에 절대 남기지 않고 Secrets를 통해 주입하는 것이 핵심입니다 — 이것 하나만 기억해도 이 글을 읽은 보람이 있습니다. 캐시 서버 자체는 Docker Compose 하나, 환경변수 세 개로 시작할 수 있고, CI 빌드 시간을 Mercari 사례 기준 최대 50% 이상 줄여줄 수 있습니다.
지금 바로 시작해볼 수 있는 3단계입니다.
docker-compose.yml을 복사해서docker compose up -d로 캐시 서버를 로컬에 띄워봅니다 —http://localhost:3000/health로 서버가 뜨는지 확인하고, MinIO 콘솔(http://localhost:9001)에서turborepo-cache버킷을 생성해두면 준비가 끝납니다.- 로컬 환경에서 캐시 연결을 검증해봅니다 — 모노레포 루트에
.turbo/config.json을 만들고apiUrl을http://localhost:3000으로 지정한 뒤,TURBO_TOKEN=my-secret-token pnpm turbo run build를 두 번 실행해봅니다. 두 번째 실행에서FULL TURBO가 뜨면 캐시가 정상적으로 동작하는 것입니다. - 검증이 됐다면 서버를 접근 가능한 URL에 올리고 GitHub Secrets에
TURBO_API,TURBO_TOKEN,TURBO_TEAM을 등록해봅니다 — 워크플로env블록에 위 예시처럼 추가하면 다음 PR부터 바로 효과를 체감할 수 있습니다.
참고 자료
- Remote Caching | Turborepo 공식 문서
- GitHub Actions 연동 가이드 | Turborepo 공식 문서
- ducktors/turborepo-remote-cache | GitHub
- Custom Remote Caching | ducktors 공식 문서
- Supported Storage Providers | ducktors 공식 문서
- ducktors/turborepo-remote-cache | Docker Hub
- brunojppb/turbo-cache-server (Rust 구현체) | GitHub
- pkarolyi/garden-snail (NestJS 구현체) | GitHub
- Tapico/tapico-turborepo-remote-cache | GitHub
- rharkor/caching-for-turbo (GitHub Actions 내장 캐시 활용) | GitHub
- Turborepo Remote Cache로 CI 가속화 | Mercari Engineering (2026.02)
- S3 + GitHub Actions 연동 | januschung.github.io (2025.05)
- Vercel Remote Cache 무료화 공식 발표 | Turborepo Blog
- Alternative remote caching hosts | Turborepo GitHub Discussions