- Cross-border RTT tax: hundreds of small HTTPS round trips to rubygems.org or CDN edges turn
bundle installinto a wall-clock hog even when bandwidth looks fine. - Git gem amplification: every
git:orgithub:entry clones or fetches over the same long-haul path; shallow clones help until a ref needs history you did not fetch. - Parallelism collisions: raising
BUNDLE_JOBSwithout watching disk queue depth on shared Apple Silicon hosts creates jitter for Xcode or CocoaPods jobs on the same runner. - Lockfile drift: developers resolve on laptops with different mirrors or Bundler minors; CI silently rewrites
Gemfile.lockor fails checksum steps.
Scenarios & bottlenecks
Start by naming the dominant cost: metadata (dependency API calls), gem .gem downloads, or git object transfer. Remote Mac pools in 2026 often mix Ruby with iOS toolchains; a “fast” install that saturates NVMe queues still delays unrelated jobs. If your stack also pulls containers or Java artifacts, read Git and Docker pull acceleration and cross-border Git, npm, and Homebrew CI optimization so you do not tune Bundler in isolation.
Decision rule: choose the smallest change that removes the bottleneck. Prefer mirror + lock discipline before vendoring every gem; prefer vendoring before running a fragile multi-hop proxy chain nobody can reproduce locally.
Parallel install & connection-pool parameters
BUNDLE_JOBS controls how many gems Bundler resolves or fetches concurrently during bundle install. BUNDLE_RETRY sets how many times Bundler retries a failed network request per gem operation. Both apply to rubygems-style downloads; Git-sourced gems still depend on git behavior and authentication.
| Parameter / command | Starting point (shared remote Mac) | When to change |
|---|---|---|
export BUNDLE_JOBS=6 |
4–6 jobs when multiple tenants share one Mac; 8 only on dedicated hosts with spare IO. | Drop if diskutil activity or latency spikes coincide with install; raise only after confirming headroom. |
export BUNDLE_RETRY=5 |
3 on stable paths (Bundler default); 5 on flaky cross-border links. | Beyond ~5, prefer mirror health checks and outer shell retries instead of infinite inner retries. |
bundle install --jobs=6 --retry=5 |
Same numbers as env vars; useful in one-off scripts. | Keep flags and env consistent across local and CI to avoid “works on my laptop.” |
| Outer shell retry threshold | 3 attempts with sleeps 2s, 4s, 8s exponential backoff. | Stop after third failure and page mirror or proxy owners—do not mask systemic outages. |
GIT_HTTP_LOW_SPEED_LIMIT / GIT_HTTP_LOW_SPEED_TIME |
Example: 1000 bytes/s for 300 seconds on very slow links. |
Use alongside BUNDLE_RETRY for Git gems; tune per region after measuring clone stalls. |
Copy-paste CI env block (bash):
export BUNDLE_JOBS=6
export BUNDLE_RETRY=5
export BUNDLE_PATH="$PWD/vendor/bundle"
export BUNDLE_WITHOUT="development:test" # only if your pipeline truly skips those groups
for i in 1 2 3; do
bundle install --frozen && break
sleep $((2 ** i))
done
Swap --frozen for the frozen/deployment flag your Bundler version documents if you are not on Bundler 2.4+. Always pair frozen installs with a committed Gemfile.lock.
Cross-border mirrors & Git-source strategy
Mirrors accelerate rubygems.org by terminating TLS closer to the runner or by riding an approved corporate proxy. Git-source gems bypass rubygems mirrors entirely—you must authenticate and cache the right layer. Never paste production tokens into public logs; map CI secrets into Bundler or Git config in the job’s environment only.
| Source pattern | Typical setup | Example command / env |
|---|---|---|
| Official rubygems.org | Default when latency and policy allow; simplest mental model. | No mirror; ensure outbound 443 is stable. |
| Regional / vendor mirror | Compliance-approved host that mirrors rubygems indices and gems. | bundle config set --global mirror.https://rubygems.org https://gems.example-mirror.example/ |
| Enterprise gem proxy | Artifactory, Nexus, or internal rubygems remote with audit trails. | bundle config set --global mirror.https://rubygems.org https://nexus.example.com/repository/rubygems.org/ |
| GitHub HTTPS gems | github: shorthand or explicit git HTTPS URLs. |
export BUNDLE_GITHUB__COM=x-access-token:${GITHUB_TOKEN} |
| SSH Git remotes | Private forks or servers that disallow HTTPS tokens. | export GIT_SSH_COMMAND='ssh -i ~/.ssh/ci_ed25519 -o StrictHostKeyChecking=yes' |
After changing mirrors, regenerate Gemfile.lock on a clean machine that uses the same mirror chain as CI. Mixed chains are the fastest way to get “checksum valid here, invalid there” failures.
Lockfile drift, vendor/cache, and CI cache keys
Treat Gemfile.lock as the binary contract between developers and CI. Caches should invalidate when that contract changes or when the Ruby interpreter changes. vendor/cache stores .gem files for offline or low-connectivity installs; BUNDLE_PATH (for example vendor/bundle) stores expanded installs and is larger and more platform-specific.
| Artifact | CI cache strategy | Example cache key fragment |
|---|---|---|
vendor/cache (.gem tarballs) |
Cache when you run bundle package in-repo or in an upstream job artifact. |
gems-${{ hashFiles('**/Gemfile.lock') }} (GitHub Actions style) |
| Bundler global cache (gem downloads) | Point at fast APFS path via bundle config set cache_path or default under home; cache directory in CI. |
bundler-${{ runner.os }}-ruby${{ matrix.ruby }}-${{ hashFiles('**/Gemfile.lock') }} |
vendor/bundle install tree |
Sometimes faster restore than reinstall; watch native extension and platform differences. | Add ruby -v digest or .ruby-version hash to key. |
| Git gem objects | Cache bundler cache plus optional bare mirror; key includes lock checksum and pinned ref. | git-gems-${{ hashFiles('**/Gemfile.lock') }} |
Gemfile.lock acceptance checklist (fail CI if any item breaks):
| Check | Command / signal | Pass criteria |
|---|---|---|
| Lock present | test -f Gemfile.lock |
File committed on default branch. |
| No unexpected drift | bundle install --frozen (or equivalent) |
Exit 0; lock not rewritten. |
| Platform consistency | Inspect PLATFORMS block in lock |
Includes CI platform (often ruby or x86_64-linux / arm64-darwin as required). |
| Bundler version alignment | BUNDLED WITH section |
CI installs matching Bundler before install. |
| Git refs pinned | Git entries in lock show SHA/tag | No floating branch names in production locks. |
FAQ
Should I set BUNDLE_DEPLOYMENT? On modern Bundler it forces deployment semantics similar to legacy --deployment: vendor path, frozen behavior, and no system-gem leakage. Use it when your images are ephemeral and you want CI to mirror production constraints.
Does bundle config belong in the repo? Prefer .bundle/config checked in only when every developer and agent must share identical paths; otherwise inject config in CI with documented env vars so laptops stay flexible.
How do I debug slow Git gems only? Run a throwaway job with bundle install --verbose, watch for repeated clone attempts, and confirm shallow fetch settings. Pair with the shell retry table above instead of raising BUNDLE_RETRY without bound.
Summary
Pick mirror versus direct rubygems based on compliance and measured RTT, not folklore. Cap BUNDLE_JOBS on shared remote Macs, raise BUNDLE_RETRY only into the single-digit range, and wrap installs with a three-attempt shell backoff. Commit Gemfile.lock, key caches on its hash plus Ruby version, and run frozen installs in CI. When you need Apple Silicon close to your mirrors—or a dedicated machine so Ruby installs stop fighting Xcode builds—renting a remote Mac from MacPull keeps queues short without buying hardware.
No-login next steps: homepage, purchase, help center, technical blog.
Buy speed and isolation the same way you buy compile capacity: pin the lockfile, tame parallel network work, and place CI where your gems actually land.