Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save flyingwebie/1f35382ed0b69f00460169ef17480927 to your computer and use it in GitHub Desktop.

Select an option

Save flyingwebie/1f35382ed0b69f00460169ef17480927 to your computer and use it in GitHub Desktop.
Deploying `ducktors/turborepo-remote-cache` on Coolify

Deploying ducktors/turborepo-remote-cache on Coolify

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 --user flag, which you need to run the container as root. Docker Compose in Coolify accepts the full user: field.


Why a remote cache

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.

Why self-host (vs. Vercel's remote cache)

  • 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.

Why Docker Compose (not Docker Image service type)

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:

  1. Coolify creates Docker named volumes owned by root:root on the host.
  2. The container's non-root node user can't write to a root-owned mount point.
  3. When Turbo PUTs an artifact, the server calls fs.mkdir('/cache', { recursive: true })EACCES → returns 412 Precondition Failed.
  4. 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:.

The compose file

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:

Key decisions

  • 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 /cache then drops back to non-root.
  • :1.15 pinned — avoid :latest drift. 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-data on first deploy and persists it across redeploys. Auto-cleanup on service delete.

Deploy steps

1. Mint secrets locally

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_KEY

Store in a password manager. The CI token is the first entry in the comma-delimited TURBO_TOKEN value; dev tokens follow.

2. Create the Coolify service

  • Project → + Add ResourceDocker Compose (NOT Docker Image).
  • Paste the YAML above.
  • Save.

3. Set environment variables

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>

4. Attach a domain

General tab → Domains → https://cache.your-domain.com → Save. Coolify provisions TLS automatically via its reverse proxy.

5. Deploy

Click Deploy. Wait for the healthcheck.

Verification

From Coolify's Terminal tab

whoami        # should print "root" (not "node")
ls -la /      # should show "cache" in the root listing
ls -la /cache # should be empty but writable

From a developer machine

export 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".

In Coolify's service logs

  1. First run:

    • GET /v8/artifacts/<hash>404 (cache empty, nothing to serve)
    • PUT /v8/artifacts/<hash>200 (artifact stored)
  2. Second run (after scrubbing local caches):

    • GET /v8/artifacts/<hash>200 (artifact served from remote)

Troubleshooting

412 Precondition Failed on every PUT + EACCES: permission denied, mkdir '/cache'

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).

Only POST /v8/artifacts/events in logs, no GET/PUT

/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/turbo

>>> FULL TURBO but no GETs in Coolify logs

Same 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.

401 Unauthorized on PUT/GET

Token not in the server's TURBO_TOKEN comma list, or your local $TURBO_TOKEN doesn't match any entry. Re-check both sides.

403 Forbidden on GET after successful PUT

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.

Cache appears to work but feels slow

  • 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=local writes to the mounted volume synchronously. On slow disks this bottlenecks.

Security notes

  • 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_TOKEN comma list, deploy, update consumers, remove old token, deploy again. Zero-downtime.

ACL limitation (by design)

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=jwt with 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.

When this compose doesn't apply

  • Outside Coolify (plain Docker, Kubernetes, etc.): you can run with the image's default non-root node user if you pre-chown the volume mount to uid 1000 during setup. On Kubernetes, use an initContainer that runs chown -R 1000:1000 /cache before the main container starts.
  • If you need read-only dev tokens: see the ACL limitation above.

References


Based on a real deployment that took several debug cycles to get right. If this saves you the same afternoon, star the gist.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment