caching astro build assets on CI
/ 3 min read
Table of Contents
My Astro site builds were starting to take over 10 minutes on GitHub Actions for this humble blog. Every deployment meant ~200 API calls to Geoapify for static maps, plus regenerating all the Satori-based Open Graph images. Super annoying if I wanted to deploy just a text tweak or a new blog post that even didn’t touch existing images.
Today I got that build time down to 33 seconds. Here’s what I did.
The Initial Problem
The site uses Geoapify’s API to generate static map images for photo pages - both individual photos and paginated index pages with multiple markers. With 158 photos plus pagination and tag pages, that’s ~250 map requests per build.
Rate limiting at 5 requests/second meant a minimum of 40+ seconds just waiting. But the real issue was that every single build regenerated everything from scratch.
First Attempt: Astro’s Built-in Cache
My initial thought was simple: Astro 5 already caches image optimizations in node_modules/.astro. Just add that directory to GitHub Actions cache and we’re done, right?
- name: Cache Astro Images uses: actions/cache@v4 with: path: node_modules/.astro key: astro-image-cache-${{ hashFiles('src/content/photo/**') }}I tested it. Cache saved successfully (79.6 MB). Cache restored on the next build. Everything looked great.
Except the build still took 10 minutes. The Geoapify API was still being called 200+ times.
Turns out Astro’s image cache only stores the optimized output - it doesn’t prevent the HTTP requests to validate remote image freshness. The cache worked perfectly for the image processing, but the API calls still happened every time.
The Pivot: Fetch-Level Interception
The site already had a rate-limiting wrapper around Geoapify requests. That became the perfect place to add caching before the fetch happened.
The key insight: cache the raw API responses as files, keyed by URL hash:
function getCacheKey(url: string): string { return createHash("md5").update(url).digest("hex");}
const cachePath = join(CACHE_DIR, `${cacheKey}.json`);This meant I could check the cache and return a cached Response object without ever hitting the network. Perfect.
Sanity Checks
Working on this I noticed I needed some sanity checks if the cache contained garbage (that happened during development, but who knows how I might still mess this up once it was already working and was deployed). So restoring images from cache now checks for PNG signatures (magic bytes 89 50 4E 47), checks for reasonable file sizes and validates some required fields. If anything looks wrong, gracefully we’ll fall back to fetching fresh.
Bonus Round: OG Images
With Geoapify working, I noticed the photo index pages still took a while. Those use Satori to generate Open Graph images - a 3x3 grid of photos with metadata overlaid.
Satori runs completely outside Astro’s image pipeline. Every build meant:
- Reading 9 photos with Sharp
- Resizing each to 181x181
- Converting to base64
- Rendering SVG with Satori
- Converting SVG→PNG with Resvg
I applied the same caching pattern:
function getOgCacheKey( title: string, subtitle: string, details: string, photoCount: number,): string { const input = `${title}|${subtitle}|${details}|${photoCount}`; return createHash("md5").update(input).digest("hex");}Cache the final PNG, skip all the expensive processing on cache hits.
GitHub Actions Integration
The final piece was making sure CI preserved these caches between runs:
- name: Cache Astro Build Assets uses: actions/cache@v4 with: path: node_modules/.astro key: astro-cache-${{ hashFiles('src/content/photo/**', 'src/assets/photos/**') }} restore-keys: | astro-cache-This caches the entire node_modules/.astro directory, which now includes:
geoapify-cache/- ~250 API responses (~60 MB)og-cache/- ~15-20 OG imagesassets/- Astro’s optimizations (~1128 files)data-store.json- content cache
The cache key uses hashFiles() so adding or modifying photos triggers regeneration. Otherwise, pure cache hits.
The Results
Before: 10+ minutes After: ~30 seconds!
On builds without photo changes:
- Zero Geoapify API calls (console shows all
✅ Cache HIT) - Zero OG image generation (no Satori/Sharp processing)
- All images and maps still display perfectly
The cache size is ~100-120 MB, well within GitHub Actions’ 10 GB limit.
Nice!