Prerequisites
Before starting, confirm the following:
- The project is a SolidStart site using the
cloudflare-modulepreset (the shared client-site stack) - Node.js v18+ and npm are available;
npx wranglerruns without error - You can run a local build (
npm run build) in the project directory - You know whether the project deploys manually via
scripts/deploy.shor automatically via Cloudflare Workers Builds (Git integration)
Symptoms this runbook covers:
wrangler deployfails with a script-size error at or near the 3 MB Workers free-tier limit- A push to
mainon an auto-deploy project (Workers Builds) silently fails at the Cloudflare build step, and the live site never picks up the change - The server bundle grew suddenly after adding images to the repo
Step 1: Measure the Bundle
Build locally and check the server output size before changing anything.
npm run build
# Entry bundle size
ls -lh .output/server/index.mjs
# Total server output, and the largest chunks
du -sh .output/server
du -sk .output/server/chunks/*.mjs | sort -n | tail -10
For the size Cloudflare actually evaluates, do a dry-run deploy:
npx wrangler deploy --dry-run --outdir /tmp/wrangler-dry
du -sh /tmp/wrangler-dry
The deploy scripts in this stack enforce a hard gate of 3145728 bytes (3 MB) on .output/server/index.mjs, which is a conservative proxy for the limit. If any single chunk under .output/server/chunks/ is multiple megabytes, you have found the problem area.
Verify: You have a number. Either the bundle is over (or close to) 3 MB and you continue, or it is small and the failure is something else entirely (check the Cloudflare build logs for the real error before going further).
Step 2: Confirm Base64-Bundled Images in Server Chunks
The usual culprit is image data embedded as base64 inside server-side JavaScript chunks.
# Which chunks contain embedded image data
grep -rl 'data:image/' .output/server/chunks | head
# How big are they
grep -rl 'data:image/' .output/server/chunks | xargs du -h | sort -h
Then measure what is sitting in public/:
find public -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name '*.webp' -o -name '*.svg' \) -exec du -ch {} + | tail -1
Verify: The oversized chunks contain data:image/ payloads, and the raster total in public/ roughly accounts for the bundle bloat. If the chunks are large but contain no image data, the problem is a heavy dependency, not this runbook -- inspect imports instead.
Step 3: Understand the Root Cause
Nitro (SolidStart's server engine) base64-bundles assets it finds in public/ into the server chunks. Small files are harmless, but product photos, brand SVGs, and any multi-MB raster get inlined into .mjs files and blow past the Workers 3 MB limit. The images are still needed as static assets, they just must not pass through the Nitro build.
Step 4: Apply the Move-Out/Copy-Back Fix
The fix pattern, proven in scripts/deploy.sh on the reference sites (/Users/dein/Code/projects/websites/AAFireworks/scripts/deploy.sh and /Users/dein/Code/projects/websites/matts-delicious-meat/scripts/deploy.sh):
- Move image directories out of
public/before the build - Run the build (the server bundle stays small)
- Copy the images into
.output/public/so they are served as static assets - Restore the images to
public/for local dev - Deploy with wrangler
Minimal version, adapted from the AAFireworks script:
#!/usr/bin/env bash
set -euo pipefail
TMP_IMAGES="$(mktemp -d)"
# 1. Stash image dirs out of public/ before build
if [ -d public/images ]; then mv public/images "$TMP_IMAGES/"; fi
if [ -d public/logos ]; then mv public/logos "$TMP_IMAGES/"; fi
# 2. Build (server bundle stays small)
npm run build
# 3. Copy into .output/public/ and 4. restore for local dev
mkdir -p .output/public
if [ -d "$TMP_IMAGES/images" ]; then
cp -r "$TMP_IMAGES/images" .output/public/
mv "$TMP_IMAGES/images" public/
fi
if [ -d "$TMP_IMAGES/logos" ]; then
cp -r "$TMP_IMAGES/logos" .output/public/
mv "$TMP_IMAGES/logos" public/
fi
# Size gate before deploying
BUNDLE_BYTES=$(stat -f%z .output/server/index.mjs 2>/dev/null || stat -c%s .output/server/index.mjs)
LIMIT=$((3 * 1024 * 1024))
echo "Server bundle: $BUNDLE_BYTES bytes (limit $LIMIT)"
if [ "$BUNDLE_BYTES" -gt "$LIMIT" ]; then
echo "ERROR: bundle exceeds 3 MB limit"; exit 1
fi
# 5. Deploy
npx wrangler deploy
Adjust the directory names to whatever the project actually keeps under public/. If the project already has a scripts/deploy.sh, fix it there rather than creating a second script.
Verify:
ls -lh .output/server/index.mjs
ls .output/public/images | head
The server bundle is under 3 MB and the images exist in .output/public/.
Step 5: Shrink the Images Themselves
The move-out trick keeps the Worker under the limit, but oversized rasters still cost page weight. Convert every raster to WebP before committing.
# Resize then convert (Selected Work thumbnail spec: 1280x815)
sips -z 815 1280 in.png --out t.png
cwebp -q 82 -m 6 -sharp_yuv t.png -o out.webp
# Convert without resizing
cwebp -q 82 -m 6 -sharp_yuv in.png -o out.webp
Delete the original raster after confirming the WebP renders correctly and updating references in the source.
Verify: Re-run the public/ size total from Step 2. Keep the raster total small -- valkyrie-nexus sits around 359 KB across all of public/, which is the kind of number to aim for.
Step 6: Deploy and Verify Live
For manually deployed sites, run the project's scripts/deploy.sh. For valkyrie-nexus, which is connected to Cloudflare Workers Builds, merge to main and the push auto-builds and deploys.
Verify:
# Page responds
curl -s -o /dev/null -w "%{http_code}" https://SITE/
# A moved image is served as a static asset
curl -s -o /dev/null -w "%{http_code}" https://SITE/images/SOME-IMAGE.webp
Both should return 200. On auto-deploy projects, also open the Cloudflare dashboard (Workers & Pages, then the worker, Settings, Builds) and confirm the latest build succeeded. This matters because a failed Workers Build is silent: the push lands on GitHub, nothing deploys, and nothing tells you.
Rollback
This procedure is not destructive, so rollback is simple:
- Build broken locally: The move-out script restores images to
public/even on the happy path. If a build aborted midway, check for a leftover temp directory and move the images back intopublic/by hand. - Bad deploy went live: Re-deploy the previous commit. Check out the last known-good commit and run
scripts/deploy.sh(or revert the merge onmainfor auto-deploy projects). - Auto-deploy stuck failing: Revert the commit that introduced the oversized asset, push, and confirm the next Workers Build goes green.
Maintenance Notes
- Convert every raster to WebP before committing, every time. On auto-deploy projects a committed multi-MB raster does not fail loudly, it silently kills the Cloudflare build.
- Keep the
public/raster total small and check it occasionally with thefind ... duone-liner from Step 2. - When adding a new image directory under
public/, add it to the move-out list inscripts/deploy.shin the same commit. - AAFireworks is the canonical reference for the deploy script; the BBQ site has a more heavily commented variant of the same pattern.
- Cloudflare env access in this stack goes through
nativeEvent.context.cloudflare.env, notprocess.env-- unrelated to bundle size, but the next thing that breaks if a triage turns into a larger refactor.