The Problem
Crystal test builds in Woodpecker CI were taking 415 seconds to complete.
Every pipeline run would recompile dependencies from scratch, even though most changes affected application code, not third-party libraries.
Crystal's compiler is thorough and safe, but recompiling everything on each CI run was costly - especially when running tests multiple times per day.
The Solution
Persistent Crystal cache storage was implemented using named volumes combined with a custom cache directory:
test:
image: crystallang/crystal:${CRYSTAL_VERSION}
environment:
CRYSTAL_CACHE_DIR: /cache/crystal
volumes:
# Persistent cache for compiled Crystal modules
- crystal-cache-${CRYSTAL_VERSION}:/cache/crystal
commands:
- rake test:build
- rake test:run
Note: Testing also included adding tmpfs: ["/tmp:size=2g"] but it provided no measurable improvement. The persistent cache is where the real optimization happens.
How It Works
1. Custom Cache Directory
By setting CRYSTAL_CACHE_DIR: /cache/crystal, the Crystal compiler stores compiled artifacts in a predictable location instead of the default temporary directory.
environment:
CRYSTAL_CACHE_DIR: /cache/crystal
This provides control over where Crystal stores:
- Compiled standard library modules
- Compiled dependency (shard) modules
- Precompiled object files
- LLVM intermediate representations
2. Named Volumes for Persistence
Woodpecker CI uses container volumes to persist data between pipeline runs. A named volume is mounted at the cache directory:
volumes:
- crystal-cache-${CRYSTAL_VERSION}:/cache/crystal
Key insight: Using ${CRYSTAL_VERSION} in the volume name maintains separate caches for different Crystal versions (e.g., crystal-cache-1.16.3 and crystal-cache-nightly). This prevents cache conflicts when testing against multiple Crystal versions in matrix builds.
3. What About tmpfs for /tmp?
Initial assumptions suggested that adding tmpfs for /tmp would help, since Crystal might write temporary files there during compilation. Testing was performed:
tmpfs:
- /tmp:size=2g
Result: No measurable improvement (~196s with or without tmpfs).
Why? Crystal's compilation model doesn't write much to temporary directories:
- Most I/O goes to
CRYSTAL_CACHE_DIR(which is already optimized with persistent volumes) - Crystal keeps most compilation state in memory
- The compiler creates minimal intermediate temp files
- Container overlay filesystem is "good enough" for the small amount of
/tmpusage
4. Container Overlay Storage Optimization
Woodpecker agents using container overlay storage drivers (such as Podman's overlay2) benefit from efficient layer caching.
The named volume persists between runs, and only changed files need to be written - combining with the container runtime's copy-on-write mechanism for optimal performance.
Important note: Named volumes are local to each agent. If multiple Woodpecker agents are running, each maintains its own separate cache. This means:
- First run on a new agent will be slow (cold cache)
- Subsequent runs on the same agent will be fast (warm cache)
- Pipelines may experience variable build times depending on which agent executes them
For shared caching across multiple agents, consider external cache storage solutions (S3, network volumes, or distributed cache systems).
The Impact
Before (no optimizations): 415 seconds per test build
After (persistent cache only): 196 seconds per test build
After (cache + tmpfs for /tmp): ~196 seconds (no significant change)
Improvement: 2.1x faster (53% time reduction) ⚡
Why Persistent Cache Helps, But tmpfs Doesn't
Persistent cache (/cache/crystal) - BIG WIN:
- ✅ Avoids recompiling unchanged dependencies between builds
- ✅ Speeds up subsequent builds dramatically (415s → 196s)
- ✅ Survives across pipeline runs
tmpfs for /tmp - Minimal Impact:
- ⚠️ Crystal doesn't write much to
/tmpduring compilation - ⚠️ Most I/O goes to
CRYSTAL_CACHE_DIR(already on persistent volume) - ⚠️ Modern container overlay filesystem is "good enough" for the small amount of temp files
Why This Makes Sense
Crystal's compiler architecture is smart:
- Compiled artifacts go to cache directory - this is where the bulk of I/O happens
- Temporary files are minimal - Crystal doesn't create many intermediate temp files
- Most work is in-memory - the compiler keeps most data structures in RAM during compilation
- Cache hits dominate - on warm cache, there's very little new compilation happening
The persistent cache eliminated the expensive recompilation (hundreds of seconds). The remaining time is mostly:
- Linking compiled objects
- Running tests (database operations)
- Test framework overhead
Adding tmpfs for /tmp doesn't help because there's simply not much disk I/O happening there.
What Gets Cached?
On the first run, Crystal compiles everything:
- Standard library (~100MB of compiled code)
- Third-party shards (dependencies)
- Application code
On subsequent runs, Crystal reuses:
- ✅ Unchanged standard library modules
- ✅ Unchanged dependency code
- ✅ Unchanged application code
- ❌ Only recompiles what changed
Matrix Builds: One Cache Per Version
CI testing runs against multiple Crystal versions:
matrix:
CRYSTAL_VERSION:
- 1.16.3
- 1.19.1
- nightly
Each version gets its own cache:
-
crystal-cache-1.16.3- old version cache -
crystal-cache-1.19.1- stable version cache -
crystal-cache-nightly- nightly version cache
This prevents cache corruption when compiler internals change between versions.
Implementation Details
Complete Woodpecker Configuration
steps:
test:
image: crystallang/crystal:${CRYSTAL_VERSION}
environment:
DATABASE_URL: postgres://postgres:dbpgpassword@postgres:5432/api_test
CRYSTAL_CACHE_DIR: /cache/crystal
volumes:
- crystal-cache-${CRYSTAL_VERSION}:/cache/crystal
commands:
- crystal env
- rake test
Repository Trust Requirement
Important: Using volumes: and tmpfs: in Woodpecker CI requires the repository to be marked as "Trusted".
Why trust is required:
-
volumes:- Allows mounting host volumes into containers, providing access to persistent storage -
tmpfs:- Allows mounting in-memory filesystems, requiring elevated container privileges
Both features give containers more access to the host system. Woodpecker restricts these features to prevent potentially malicious pipeline configurations from compromising the CI infrastructure.
How to enable trust:
- Navigate to repository settings in Woodpecker UI
- Enable the "Trusted" checkbox
- Only repository administrators can modify this setting
Security consideration: Only enable trust for repositories with controlled access and reviewed pipeline configurations. Trusted pipelines can potentially access sensitive data on the CI host system.
Key Takeaways
- Named volumes survive pipeline runs - Woodpecker's volume mounting is key for persistence
- Caches are per-agent - each Woodpecker agent maintains its own cache, not shared across agents
-
Repository must be marked as "Trusted" - required for using
volumes:andtmpfs:features - 2x speedup is achievable - especially for projects with many dependencies
- Focus on what matters - persistent cache is the killer feature for Crystal CI
Potential Issues and Solutions
Problem: Cache grows too large
Solution: Periodically clean old caches or set retention policies in Woodpecker agent configuration.
Problem: Cache corruption after Crystal upgrade
Solution: The ${CRYSTAL_VERSION} suffix naturally creates new caches for new versions.
Problem: Multiple agents don't share caches
Solution: This is by design - named volumes are local to each agent. Each agent maintains its own cache, which means:
- First build on each agent will take the full 415 seconds (cold cache)
- Subsequent builds on the same agent will take ~196 seconds (warm cache)
- Build times vary depending on which agent picks up the job
For consistent performance across all agents, consider:
- Use agent labels to pin jobs to specific agents
- Implement external cache storage (S3, NFS, network volumes)
- Accept variable build times as a trade-off for distributed load
Problem: Volumes not mounting or permission errors
Solution: Verify the repository has "Trusted" status enabled in Woodpecker settings. Without trust, volumes: and tmpfs: directives are silently ignored or produce permission errors.
Comparison with Other CI Systems
| CI System | Cache Strategy |
|---|---|
| GitHub Actions |
actions/cache with path /home/runner/.cache/crystal
|
| GitLab CI |
cache: directive with key: ${CI_COMMIT_REF_SLUG}
|
| Woodpecker | Named volumes with version-specific keys |
Woodpecker's approach is simpler - no cache upload/download steps, just persistent volumes.


Top comments (0)