Speeding up Astro builds and improving deployment
When I first started moving this site to Astro I decided that I wanted a truly statically-generated site. In other words, I want astro build to produce a folder of plain old files which can be served by any webserver which can serve files. So while I currently deploy to Vercel, nothing about the build depends on Vercel. This means I can use a GitHub Action to build the site and then dump the resultant dist/ on Vercel (or anywhere else I want).
I’ve been doing a lot of work here recently and noticed two problems:
- Slow builds. Every time I push to main I have to wait for ages. Builds were taking upwards of five minutes from push to deploy, with
astro buildalone taking 4m 30s. - Rubbish Caching. Just dumping a static site meant Vercel was serving everything with
cache-control: public, max-age=0, must-revalidateheaders, including content-hashed assets and static files like fonts. The lack of Vercel config was also preventing my custom 404 page from being served and there were a few other little bits which are only possible to fix by configuring how my files are served by Vercel.
Before looking at anything else we should see if we can reduce the weight of our build-time dependencies. Individually this is unlikely to make a huge difference to build time but it’s good hygiene and reduces supply-chain risks to boot.
I ended up removing some old unused packages and moving a bunch to devDependencies since they’re not needed at build-time. I replaced unfurl.js with a small utility function, and noticed that while I was using Playwright for end-to-end tests & rendering mermaid diagrams, I was also loading puppeteer to grab screenshots in a scraper script. Since puppeteer bundles Chromium that’s not ideal, so now I’m using playwright everywhere.
Speeding up the Astro build on GitHub
Three jobs run per push (all defined in deploy.yml): Check → Build → Deploy. Measured on a real run against main I got:
| Job | Time | The expensive bit |
|---|---|---|
| Check | 102s | Playwright install ~23s, e2e tests ~30s |
| Build | 313s (5m 13s) | astro build was 4m 30s of it |
| Deploy (prod) | 48s | npx vercel re-downloading the CLI took ~24s |
Inside astro build, two phases were the main culprits and both are cacheable across runs:
| Phase | Time | What it is |
|---|---|---|
| OG image generation | 92s | 167 images @ ~553ms average |
| Image optimisation | 2m 4s (124s) | 312 transforms (avif + webp + jpg) from ~100 source images |
Together, these account for about 80% of the actual build time and ~70% of the Build job. On top of that, the build job waited for the check job to finish before starting, which added about 100s to every run.
Fix 1 — Cache node_modules/.astro
GitHub Actions gives every job a clean machine. To carry files between runs we can use actions/cache, which takes a path and a key to store it under. On the next run, if a cache exists for that key it gets restored to path. I was already doing this for Playwright’s browsers, which are slow to download:
- name: Cache Playwright uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} restore-keys: | playwright-${{ runner.os }}-This is a common pattern: the key is a hash of bun.lock, so the cache is reused until any of my dependencies change, at which point the key changes and fresh browsers are downloaded and re-cached. The restore-keys line is a prefix fallback — if there’s no exact match, GitHub restores the most recent cache whose key starts with playwright-<os>-, so a dependency bump still starts from a recent cache rather than from nothing.
Astro keeps its own cache in node_modules/.astro/ and the useful part for us is how it handles images. Every optimised image is written out with a hash of the source file and its transform baked into the filename. So when Astro goes to optimise an image, if a file with that exact hash already exists, the work is already done and it skips it.
Astro does this on disk but I wasn’t caching this across builds so every image was regenerated on every build. The fix is obvious: persist node_modules/.astro/ between runs with actions/cache, like we did with Playwright. The only interesting question is what to key it on?
It’s tempting to copy the Playwright pattern and key on a hash of the content (hashFiles('src/content/**') or similar), but that throws away the entire image cache the moment any content changes, which is most pushes. So instead I’m keying on the commit SHA which is unique to every push. Combined with the restore-keys prefix fallback, this means:
- Every successful run writes a fresh cache snapshot under its own SHA (remember, keys are write-once).
- Every run restores the most recent snapshot via the prefix, regardless of what changed.
- Astro’s own per-image hashing then re-processes only the images that actually changed.
- name: Cache Astro build output uses: actions/cache@v4 with: path: node_modules/.astro key: astro-cache-${{ runner.os }}-${{ github.sha }} restore-keys: | astro-cache-${{ runner.os }}-This is the pattern withastro/action uses and it drastically sped up my build times.
Fix 2 — Add a file-cache to OG image generation
Astro’s content-addressed caching only applies to its own image optimisation, and my OG images are generated by an Astro endpoint (a .png route that renders each post’s social-share image with satori and resvg). Astro doesn’t cache this kind of thing, so even with node_modules/.astro/ cached on GH Actions, every build re-renders all the OG images from scratch.
What if we also cached these files to node_modules/.astro/ in a similar way to Astro’s image cache?
Before rendering, we hash everything that affects the image and then if a PNG already exists for that hash, we return it; otherwise we render it, write the PNG, and return it. Here’s the gist of og-image-generator.ts:
const CACHE_DIR = path.join(process.cwd(), 'node_modules', '.astro', 'og-cache');const CACHE_VERSION = 'v5';
// Content-addressed cache key: any change to the data, template, or// dimensions produces a different key, so edits regenerate the image.const cacheKey = createHash('sha256') .update(JSON.stringify({ data, template, width, height, v: CACHE_VERSION })) .digest('hex');const cachePath = path.join(CACHE_DIR, `${cacheKey}.png`);
// Cache hit — return the previously rendered PNG.try { return await fs.readFile(cachePath);} catch { // Miss (or unreadable) — fall through and generate.}
// ... satori + resvg render ...
// Cache the successful rendertry { await fs.mkdir(CACHE_DIR, { recursive: true }); await fs.writeFile(cachePath, pngBuffer);} catch (cacheError) { console.warn('Failed to write OG image cache:', cacheError);}A few things make this safe to do:
- It’s only safe to content-hash because the render is deterministic. I embed static TTF fonts straight into
satori({ fonts })rather than letting it fetch them, so identical inputs always produce a byte-identical PNG. If the fonts came over the network or from the system, the same inputs could render differently and a content hash would be a lie. - The key contains everything that affects the output —
data(the post’s title, description, url and type), the template name, and the dimensions. Editing a post’s title means only that image is regenerated. CACHE_VERSIONis a manual override. Things that are the same for every image (template markup, branding etc) aren’t in the per-image key, so if I change how the OG images look none of the keys will change and I’d end up serving the old cached versions. BumpingCACHE_VERSIONchanges every key at once so I can just bump this to invalidate the whole lot.- Writes are best-effort and the fallback isn’t cached. A failed cache write never fails the build, and if satori falls over and we render a simpler Sharp-generated fallback instead, that is deliberately not cached.
Fix 3 — Run Check and Build in parallel and only deploy after both
The check job just runs bun run check:all which basically does this and fails if any checks fail:
tsc --noEmit &&astro check &&prettier --check . &&eslint . &&vitest run tests/unit &&playwright testThe build job just runs bun run build, prepares an artifact for Vercel and uploads it using actions/upload-artifact. The deploy job grabs that artifact and drops it on Vercel.
There’s no reason at all why check and build shouldn’t run in parallel, so long as deploy waits for both of them to finish successfully.
Before: checks → build → deploy (build waited on checks, ~100s idle). After: Check and Build run in parallel, and Deploy waits on both.
jobs: checks: # Starts immediately build: # Starts immediately (dropped `needs: checks`) deploy-production: needs: [checks, build] # both must pass before anything ships if: github.ref == 'refs/heads/main'Fix 4 — Stop npx re-downloading the Vercel CLI
The deploy job ran npx vercel which fetched a fresh copy of the Vercel CLI on every deploy (adding ~24s) because ubuntu-latest no longer pre-bundles it. Pinning the CLI version in an env var and cacheing npm’s download cache means npx resolves from that instead of re-downloading.
env: VERCEL_CLI_VERSION: '54.6.1'
# ...in each deploy job:- name: Cache Vercel CLI uses: actions/cache@v4 with: path: ~/.npm key: vercel-cli-${{ runner.os }}-${{ env.VERCEL_CLI_VERSION }}- name: Deploy to Vercel run: npx vercel@${{ env.VERCEL_CLI_VERSION }} deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}So how much faster did things get?
Measured before & after on the Build job:
| Phase | Cold (before) | Warm (after) |
|---|---|---|
| Static entrypoints (HTML + OG) | 2m 17s | 48s (OG cached) |
| Image optimisation (Sharp) | 2m 4s | ~80ms (cached) |
astro build total | 4m 30s | 58.7s |
| Build job wall-clock | 4m 56s | ~1m 54s |
Adding some light Vercel config
Because we’re deploying an already-built site, the deploy job uses vercel deploy --prebuilt to stop Vercel trying to run its own build. This means it won’t read vercel.json – the only config consulted is .vercel/output/config.json (the Build Output API). By default this is basically empty, which is why my custom 404 page wasn’t being served and all my routes had cache-control: public, max-age=0, must-revalidate.
The solution is to include a lightweight vercel.output-config.json in the repo and have CI copy it to .vercel/output/config.json at deploy time. Here’s the whole file, block by block. Each "continue": true rule adds headers and keeps matching.
{ "src": "^/_astro/(.*)$", "headers": { "cache-control": "public, max-age=31536000, immutable" }, "continue": true },{ "src": "^/fonts/(.*)$", "headers": { "cache-control": "public, max-age=31536000, immutable" }, "continue": true },/_astro/* contains content-hashed JS, CSS & images, and /fonts/* contains version-stamped static font files. So our cache control can be immutable with a 1 year max-age. The filename changes every time the content does, which invalidates the cache. This alone is a huge performance boost for people viewing my site.
{ "src": "^/(favicon|avatar|avatar-circle|icon|end-mark|og-default).*$", "headers": { "cache-control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" }, "continue": true },Unhashed static assets in public/ which get 1h for browsers and 1 day for edge and SWR because their URLs don’t change with their content.
{ "src": "^/.*\\.xml$", "headers": { "cache-control": "public, max-age=600, s-maxage=3600, stale-while-revalidate=86400" }, "continue": true },RSS/feeds (*.xml) get 10 mins for browsers and 1h for edge & SWR.
If you’re wondering, s-maxage is consumed by Vercel’s CDN and stripped from the responses sent to clients — so a browser only ever sees public, max-age=…, while the edge gets the longer cache lifetime.
Redirects & rewrites:
{ "src": "^/cv\\.pdf$", "status": 308, "headers": { "location": "/cv-danny-smith.pdf" } },{ "src": "^/\\.well-known/(.*)$", "dest": "/well-known/$1" }Redirecting /cv.pdf to /cv-danny-smith.pdf with a proper 308 only matters because some user agents won’t follow other redirects if they point to binary files. We’ll come back to the well-known line shortly.
{ "handle": "filesystem" },{ "src": "/(.*)", "status": 404, "dest": "/404.html" }Adding { "handle": "filesystem" } ensures that any real file in dist/ is served, which we want with a static site. The last line ensures my custom dist/404.html is returned with the proper status code for unknown routes.
Dealing with .well-known files
I want to serve well-known URIs from https://danny.is/.well-known/*. Right now that’s just a static security.txt and a couple of other files I’ll discuss in future articles.
It turns out that Vercel doesn’t serve dotfile directories so I can’t just generate these files in dist/.well-known and be done with it. It also turns out that Astro won’t reliably build routes placed in a dotfile dir under src/pages/. So I now keep my well-known stuff in two non-dot directories:
public/well-known/for static files.src/pages/well-known/for those which need build-time generation.
Files from both end up in dist/well-known/ and the Vercel rewrite rule you saw above maps requests for https://danny.is/.well-known/* onto dist/well-known/* on Vercel.
But even if Vercel won’t serve dist/.well-known/ other webservers will so they should be in the right place in my generated site in case I decide to move off Vercel. We can achieve this with a simple postbuild script in package.json which runs
rm -rf dist/.well-known && cp -R dist/well-known dist/.well-knownSince this runs automatically after every astro build, I always get a proper .well-known/ directory.
Wrapping up
A typical content push now goes from commit to live in around two minutes instead of six, my assets finally cache the way they should, and my 404 page actually shows up. Best of all, none of this cost me the thing I cared about in the first place: astro build still spits out a plain folder of files that’ll run on any webserver.