TL;DR: If you're deploying ducktors/turborepo-remote-cache on Coolify and hitting
EACCES: permission denied, mkdir '/cache'on every PUT, use the Docker Compose service type, not Docker Image. Coolify's Docker Image form filters the--userflag, which you need to run the container as root. Docker Compose in Coolify accepts the fulluser:field.
Turborepo computes a deterministic hash for every task based on its inputs — source files, env vars listed in globalEnv, dep graph, and turbo.json itself. If a task with the same hash has been built before and its outputs are cached, Turbo skips execution and replays the cached artifact.
Without a remote cache, each machine (CI + every dev laptop) maintains its own isolated cache. They never benefit from each other's work. On monorepos with many packages, this means multi-minute builds on fresh clones and full re-runs on every CI push.
A remote cache lets CI populate artifacts once, and every developer pulls them for free on the next bunx turbo run build. Sub-second >>> FULL TURBO hits instead of minute-long cold builds.
- Cost control — no per-seat billing as your team grows.
- Data sovereignty — artifacts stay on your infra.
- Availability independence — not coupled to Vercel's uptime.
Trade-off: you manage token rotation, storage, and uptime yourself.
The ducktors/turborepo-remote-cache image sets USER node in its Dockerfile (uid 1000, non-root). This is good security practice — but it collides with Coolify's default named-volume behaviour:
- Coolify creates Docker named volumes owned by
root:rooton the host. - The container's non-root
nodeuser can't write to a root-owned mount point. - When Turbo PUTs an artifact, the server calls
fs.mkdir('/cache', { recursive: true })→EACCES→ returns412 Precondition Failed. - Turbo treats remote cache as best-effort and silently falls back to local cache. The failure is invisible on the client side — you just get worse performance without ever knowing why.
The fix: run the container as root (user: "0:0") so /cache is writable.
But you cannot set --user 0:0 via Coolify's "Docker Image" service type, because the Custom Docker Options field is a filtered allowlist (Coolify docs). Only these flags are accepted:
--ip --ip6 --shm-size
--cap-add --cap-drop --security-opt
--sysctl --device --ulimit
--init --privileged --gpus
--entrypoint
--user is silently stripped. And --privileged doesn't help either — Linux capabilities don't transfer to non-root users in a privileged container (uid 1000 still gets blocked by discretionary access control regardless of the container's capability set).
Docker Compose bypasses this filter entirely. Coolify accepts any valid compose YAML, including user:.
services:
turbo-cache:
image: 'ducktors/turborepo-remote-cache:1.15'
user: '0:0' # run as root — can mkdir /cache and write freely
restart: unless-stopped
environment:
- 'NODE_ENV=production'
- 'PORT=3000'
- 'AUTH_MODE=static'
- 'LOG_LEVEL=info'
- 'STORAGE_PROVIDER=local'
- 'STORAGE_PATH=/cache'
- 'STORAGE_PATH_USE_TMP_FOLDER=false'
- 'TURBO_TOKEN=${TURBO_TOKEN}' # injected by Coolify
- 'TURBO_REMOTE_CACHE_SIGNATURE_KEY=${TURBO_REMOTE_CACHE_SIGNATURE_KEY}' # injected by Coolify
volumes:
- 'turbo-cache-data:/cache'
expose:
- '3000'
volumes:
turbo-cache-data:user: '0:0'— runs as root. Acceptable for a cache server behind TLS + reverse proxy with no exposed shell surface. If your threat model is stricter, use an entrypoint wrapper that chowns/cachethen drops back to non-root.:1.15pinned — avoid:latestdrift. A ducktors v2 could land with a breaking protocol change.expose: ['3000']— internal-network only. Coolify's Traefik/Caddy layer routes the external domain to this port; no host-port binding needed.${TURBO_TOKEN}+${TURBO_REMOTE_CACHE_SIGNATURE_KEY}— not hardcoded. Coolify injects these from the Environment Variables tab so secrets live in one place and can be rotated without editing the compose.- Named volume (not bind mount) — Coolify creates
turbo-cache-dataon first deploy and persists it across redeploys. Auto-cleanup on service delete.
openssl rand -hex 32 # TURBO_TOKEN for CI
openssl rand -hex 32 # TURBO_TOKEN for each dev (one per dev)
openssl rand -hex 32 # TURBO_REMOTE_CACHE_SIGNATURE_KEYStore in a password manager. The CI token is the first entry in the comma-delimited TURBO_TOKEN value; dev tokens follow.
- Project →
+ Add Resource→ Docker Compose (NOT Docker Image). - Paste the YAML above.
- Save.
In the service's Environment Variables tab:
| Variable | Value |
|---|---|
TURBO_TOKEN |
<CI_TOKEN>,<DEV_TOKEN_1>,<DEV_TOKEN_2>,... (comma-delimited) |
TURBO_REMOTE_CACHE_SIGNATURE_KEY |
<signature key> |
General tab → Domains → https://cache.your-domain.com → Save. Coolify provisions TLS automatically via its reverse proxy.
Click Deploy. Wait for the healthcheck.
whoami # should print "root" (not "node")
ls -la / # should show "cache" in the root listing
ls -la /cache # should be empty but writableexport TURBO_API=https://cache.your-domain.com
export TURBO_TEAM=<your-team-slug>
export TURBO_TOKEN=<your-dev-token>
cd /path/to/your/monorepo
# Scrub BOTH local cache layers — rm -rf .turbo alone is NOT sufficient
rm -rf .turbo node_modules/.cache/turbo
bunx turbo run build # first run — populates cache
rm -rf .turbo node_modules/.cache/turbo
bunx turbo run build --summarize # second run — should hit remote
# Verify per-task cache source
ls -t .turbo/runs/*.json | head -1 | xargs cat | \
jq '.tasks[] | {task: .taskId, source: .cache.source, status: .cache.status}'Each task should print "source": "REMOTE" and "status": "HIT".
-
First run:
GET /v8/artifacts/<hash>→404(cache empty, nothing to serve)PUT /v8/artifacts/<hash>→200(artifact stored)
-
Second run (after scrubbing local caches):
GET /v8/artifacts/<hash>→200(artifact served from remote)
You're on the Docker Image service type. Switch to Docker Compose per the YAML above. --user 0:0 cannot be set via Custom Docker Options — it's silently filtered. --privileged + --cap-add SYS_ADMIN don't help either (capabilities don't transfer to non-root users).
/events is telemetry-only and doesn't touch the filesystem. If you see only events traffic, both your runs were served by local cache at node_modules/.cache/turbo/. Note: rm -rf .turbo does NOT wipe this — .turbo/ only holds run metadata. You must clear both:
rm -rf .turbo node_modules/.cache/turboSame root cause — cache hits came from local, not remote. Run with --summarize and inspect .cache.source. If it's "LOCAL", your local cache wasn't actually scrubbed.
Token not in the server's TURBO_TOKEN comma list, or your local $TURBO_TOKEN doesn't match any entry. Re-check both sides.
Signature key mismatch. If you rotated TURBO_REMOTE_CACHE_SIGNATURE_KEY, all previously-uploaded artifacts are now unreadable — that's by design (tamper detection). Either clear the cache volume and rebuild, or restore the old key.
- Check network latency from your dev machine to the cache host — a cache round-trip that takes longer than rebuilding isn't helping you.
- Check the cache host's disk I/O.
STORAGE_PROVIDER=localwrites to the mounted volume synchronously. On slow disks this bottlenecks.
- The CI token lives in GitHub Actions Secrets; developer tokens go in each dev's shell rc. Use a secret manager like 1Password CLI or direnv rather than plain-text exports where possible.
- The signature key never leaves Coolify + the CI/dev environment variables. Rotating it invalidates the entire cache by design.
user: "0:0"runs the container as root. The container is isolated behind Coolify's reverse proxy with TLS and no exposed shell — acceptable risk for a cache service, but worth a security review if your threat model is stricter.- Token rotation (quarterly or per-departure): generate a new token, append to
TURBO_TOKENcomma list, deploy, update consumers, remove old token, deploy again. Zero-downtime.
ducktors/turborepo-remote-cache with AUTH_MODE=static does NOT enforce read/write scoping per token — every token in the comma-delimited list can both read AND write. If you need strict "CI writes, devs read-only":
- Use
AUTH_MODE=jwtwith scoped claims (requires an identity provider). - Or place nginx in front of the instance and route based on HTTP method + token.
For most teams at moderate scale, equal R/W tokens combined with a signature key (which prevents artifact tampering by leaked tokens) is sufficient. The signature key ensures an attacker with a leaked dev token can't poison the cache with forged artifacts that CI would later trust.
- Outside Coolify (plain Docker, Kubernetes, etc.): you can run with the image's default non-root
nodeuser if you pre-chown the volume mount to uid 1000 during setup. On Kubernetes, use an initContainer that runschown -R 1000:1000 /cachebefore the main container starts. - If you need read-only dev tokens: see the ACL limitation above.
- ducktors/turborepo-remote-cache: https://github.com/ducktors/turborepo-remote-cache
- ducktors env var reference: https://ducktors.github.io/turborepo-remote-cache/environment-variables.html
- Turborepo remote caching docs: https://turbo.build/repo/docs/core-concepts/remote-caching
- Coolify custom Docker options (the filter): https://coolify.io/docs/knowledge-base/docker/custom-commands
Based on a real deployment that took several debug cycles to get right. If this saves you the same afternoon, star the gist.