Deleting Source Maps from the CDN After Upload
A hidden-source-map build strips the sourceMappingURL comment, but it does not stop the .map file itself from riding along into your deployment artifact and landing on the public CDN. The reliable fix is a post-upload build step that deletes every .map object from the CDN only after your error tracker confirms it has ingested the maps. This how-to shows you how to sequence that delete step correctly, gate it on a successful upload, and verify the maps are gone from the edge while symbolication still works. It is one of the concrete workflows behind the parent guide Securing Hidden Source Maps from Public Access, and it fits inside the broader Source Map Generation & Stack Trace Debugging reference. If you cannot delete the maps outright — because a self-hosted tracker refetches them on demand — pair this with Restricting Source Map Access by IP Allowlist instead.
Symptom / Trigger
Your CI pipeline uploads source maps to Sentry, then syncs dist/ to the CDN. Weeks later a security scan reports that every minified bundle has a downloadable companion map at a predictable URL:
# The bundle ships clean — no trailing sourceMappingURL comment
curl -s https://cdn.example.com/assets/checkout.a1b2c3d4.js | tail -c 80
# ...})();
# (no //# sourceMappingURL= line — hidden-source-map worked)
# But the map is still sitting on the CDN at the guessable path
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \
https://cdn.example.com/assets/checkout.a1b2c3d4.js.map
# 200 1874233
The map returns 200 with a full body. Anyone who appends .map to a bundle URL downloads your original TypeScript sources, comments, and file structure — the exact thing hidden-source-map was supposed to prevent. Because the bundle filenames are content-hashed and therefore guessable once a single deploy is observed, an attacker does not need to crawl your site; they can watch one release, note the hash pattern, and replay the same .map suffix against every asset. The map has been publicly downloadable since the first deploy that shipped it, so treat any exposure window as long-lived rather than momentary. The scan flags it now, but the leak is as old as the artifact.
Root Cause Explanation
Hiding the sourceMappingURL comment only removes the pointer browsers follow. The .map files are still emitted into the output directory, and a naive deploy step copies the entire directory to the CDN — maps included. Upload to the error tracker and deployment to the CDN read from the same folder, so the artifact that feeds symbolication is the same artifact that gets published:
# BROKEN: upload and deploy both read from dist/, maps never removed
sentry-cli sourcemaps upload --release "$VERSION" ./dist # maps go to Sentry
aws s3 sync ./dist s3://cdn-bucket/ --acl public-read # maps ALSO go public
There is no step between the two commands that removes the maps from what the CDN receives. The delete has to happen after the upload succeeds (so symbolication keeps working) and before — or immediately after — the public sync, so the edge never serves a map for longer than one deploy cycle. Ordering is the whole problem: delete too early and the error tracker has nothing to ingest, so every future stack trace stays minified; delete too late, or not at all, and the map is public for the life of the release. The correct sequence is strict — upload, confirm, then delete — and every step must fail loudly rather than silently continuing, because a swallowed error at the upload stage is exactly what produces a deployment with public maps and no symbolication to show for it.
Step-by-Step Fix
The fix is a small, ordered set of CI steps: upload first, verify the upload, delete the maps from the deployment target, then confirm the delete. Each step below is a discrete command you can drop into a pipeline job. Run them in one job on one runner so they share the same VERSION and so a failure in an early step short-circuits the rest — you never want the delete to run against a release the tracker never received.
1. Upload maps to the error tracker and capture the exit code
Do this before anything touches the CDN. The exit code is the signal that the tracker now holds a copy of every map, which is the precondition for deleting the public one.
# Upload must succeed before anything is deleted — fail the job otherwise
sentry-cli sourcemaps upload --release "$VERSION" --org acme --project web ./dist \
|| { echo "upload failed — keeping maps"; exit 1; } # abort, do not delete
2. Confirm the tracker actually ingested the artifacts
A zero exit code from the upload command is necessary but not sufficient — network retries and partial uploads can report success while the artifact list is incomplete. Query the release explicitly and gate the delete on finding the expected map by name.
# List uploaded artifacts for the release; grep proves the .map arrived
sentry-cli sourcemaps explain "$VERSION" 2>/dev/null \
| grep -q "checkout" || { echo "map not ingested"; exit 1; } # gate the delete
3. Delete the .map objects already synced to the CDN bucket
If a prior deploy pushed maps to the bucket, remove them now. Target the map extension precisely so the bundles, styles, and other assets are untouched.
# Remove every map from the public bucket; --exclude keeps the .js bundles
aws s3 rm s3://cdn-bucket/assets/ --recursive \
--exclude "*" --include "*.map" # only .map keys are targeted
4. Purge the CDN edge cache so no cached map is served
Removing the origin object is not enough while edge nodes still hold a cached copy. Invalidate the map paths so the edge re-checks the origin and starts returning 404.
# Invalidate cached .map paths so edge nodes stop returning the old 200 body
aws cloudfront create-invalidation --distribution-id "$DIST_ID" \
--paths "/assets/*.map" # forces edge to re-check origin (now 404)
5. Strip maps from the local artifact so future syncs never re-upload them
The last step closes the loop: if the build artifact still contains maps, a later rollback or re-sync will republish them. Delete them from the working directory once the tracker has its copy.
# Delete maps from dist/ AFTER upload so a later `s3 sync` cannot republish them
find ./dist -name '*.map' -type f -delete # local artifact is now map-free
Wire these steps into a single job so they run in order and share the same VERSION. Here is the complete sequence as a GitHub Actions step, with the upload gate that prevents deletion when the tracker did not receive the maps:
# .github/workflows/deploy.yml — delete maps only after a verified upload
- name: Upload, sync, then delete source maps from CDN
env:
VERSION: ${{ github.sha }}
DIST_ID: ${{ secrets.CLOUDFRONT_DIST_ID }}
run: |
set -euo pipefail # any failure aborts the job
sentry-cli sourcemaps upload --release "$VERSION" ./dist # 1. upload first
sentry-cli sourcemaps explain "$VERSION" >/dev/null # 2. confirm ingest
aws s3 sync ./dist s3://cdn-bucket/ --exclude "*.map" # 3. sync bundles only
aws cloudfront create-invalidation \
--distribution-id "$DIST_ID" --paths "/assets/*.map" # 4. purge cached maps
find ./dist -name '*.map' -delete # 5. clean local artifact
Using --exclude "*.map" on the sync is the cleanest form: the maps never reach the public bucket in the first place, so the delete becomes a defensive cleanup for any map left over from a previous deploy rather than the only line of defense. Think of the two approaches as complementary rather than redundant. The exclude keeps new maps out of the current deploy, and the recursive delete sweeps up anything an older, less careful pipeline left behind. Running both means neither a fresh mistake nor a historical one can leave a map on the edge, and the cost is a few seconds of pipeline time.
Verification
Do not trust the pipeline to have done its job just because the steps exited green. Verification is a separate concern from execution: you want independent proof, ideally from outside your own network, that the map URL is dead while everything the application and the error tracker need is still alive. After the pipeline runs, prove that maps are unreachable at the edge while the bundle and symbolication both still work:
# 1. Map must be gone from the public CDN
MAP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
https://cdn.example.com/assets/checkout.a1b2c3d4.js.map)
[ "$MAP_CODE" = "404" ] || { echo "FAIL: map still live ($MAP_CODE)"; exit 1; }
echo "OK: public map → 404"
# 2. Bundle must still be served
BUNDLE_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
https://cdn.example.com/assets/checkout.a1b2c3d4.js)
[ "$BUNDLE_CODE" = "200" ] || { echo "FAIL: bundle $BUNDLE_CODE"; exit 1; }
echo "OK: bundle → 200"
# 3. The error tracker must still resolve original frames
sentry-cli sourcemaps explain "$VERSION" | grep -q "Found" \
&& echo "OK: symbolication data present"
Expected output confirming the map is gone but nothing else broke:
OK: public map → 404
OK: bundle → 200
OK: symbolication data present
Edge Cases & Gotchas
-
Delete before invalidate leaves a cached 200. If you remove the object from the bucket but skip the CloudFront invalidation, edge nodes keep serving the cached map body until the TTL expires — sometimes 24 hours. Always pair
s3 rmwithcreate-invalidationon the*.mappath, and treat the invalidation as part of the delete, not an optional extra. -
Immutable, content-hashed filenames mean old maps linger. Because bundles use content hashes like
checkout.a1b2c3d4.js, a previous release’scheckout.9f8e7d6c.js.mapstays in the bucket unless you sweep it. Run the recursive--include "*.map"delete across the wholeassets/prefix, not just the current release’s filenames, so historical maps are cleaned up too. -
Rollbacks can resurrect deleted maps. If your rollback strategy re-runs an old pipeline that does a full
s3 syncfrom an artifact that still contains maps, the deletion is undone. Fix this at the source: strip maps from the stored build artifact (step 5) so no archived artifact carries a.mapfile forward. -
Self-hosted trackers that refetch on demand cannot use delete. Some self-hosted error tools fetch maps lazily at symbolication time rather than ingesting them up front. Deleting the map breaks those trackers. For that model, keep the map reachable but private using an allowlist or authentication layer instead of deletion.
FAQ
How do I confirm the source map actually uploaded before the delete runs?
Run sentry-cli sourcemaps explain <release> (or sentry-cli releases files <release> list) and check the output for the expected artifact names before executing any delete command. In the pipeline above, that check is a hard gate: if the artifact is missing, set -euo pipefail aborts the job and the maps stay in place, so a failed upload can never leave you with symbolication that has no maps to work from.
Will deleting maps from the CDN break my existing error grouping?
No. Once the error tracker has ingested a release’s maps, symbolication happens on the tracker’s servers using its own stored copy. The public .map file is only needed if a client or the tracker fetches it over HTTP at resolution time — which the up-front upload model avoids entirely. Deleting the CDN copy has no effect on frames the tracker already resolves for that release.
Should I delete maps or just block access to them?
Deletion is the stronger control because a file that does not exist cannot leak, be misconfigured back to public, or be found in an old CDN cache. Blocking (via IP allowlist or authentication) is the right choice only when something still needs to fetch the map over HTTP after deploy. If nothing does, delete them; if a self-hosted tracker refetches on demand, block instead.
Related
- Securing Hidden Source Maps from Public Access — parent guide covering hidden-source-map, upload-then-delete, and private bucket storage
- Source Map Generation & Stack Trace Debugging — full reference for the build-to-symbolication pipeline
- Restricting Source Map Access by IP Allowlist — the block-instead-of-delete alternative for trackers that refetch maps
- Serving Source Maps Behind Authentication — token and signed-URL access when a map must stay reachable but private