Deploying Only Changed Files

A static site build regenerates everything, but most deploys change very little. On a 2,300-page documentation site, a typical pull request changes one to five pages; yet the build output has about 9,400 files — HTML, hashed assets, images, search index fragments — and a naive deploy uploads all of them, taking minutes and invalidating the CDN cache for the whole site. Managed hosts avoid this by hashing files and uploading only what is new. Self-hosted deploys to S3, a VM or custom storage have to do it themselves.

This guide makes deploys upload only changed files — using content hashes rather than timestamps — for S3 with CloudFront and for a VM with rsync, keeps the result atomic, scopes CDN purges to the changed paths, and measures the effect. It is part of Incremental Builds and Build Caching for SSGs, and pairs with Purging the CDN Cache After a Static Deploy.

Prerequisites

  • A deterministic build: unchanged inputs produce byte-identical outputs (check by building twice and diffing).
  • A self-hosted target: S3 behind CloudFront, or a VM served by nginx or Caddy.
  • Content-hashed file names for CSS, JavaScript and images, so assets never change in place — see Cache Busting with Content-Hashed Filenames.

Step 1: Make the Build Deterministic

Uploading only changed files works only if unchanged pages produce identical bytes. Build twice from the same commit and diff:

npm run build && mv dist /tmp/a && npm run build && diff -rq /tmp/a dist | head

Every page differed on the first try, because of a "built at" timestamp in the footer, a random ID in a component, and Git dates that changed with shallow clones. Removing the timestamp (or moving it to a single build.json file), making component IDs derive from content, and fetching full history for dates made 9,396 of 9,400 files identical between builds; the remaining four were the sitemap and feeds, which legitimately include dates. The snapshot testing approach in Snapshot Testing Build Output uses the same normalization.

Step 2: Compare by Content, Not Timestamp

aws s3 sync compares size and modification time by default, and every build sets a new modification time. --size-only is unreliable, since a one-character edit rarely changes a file's size by much. The robust approach is a manifest of content hashes from the previous deploy:

// scripts/diff-manifest.mjs — compare this build with the last deployed manifest
import { createHash } from 'node:crypto';
const hash = (buf) => createHash('sha256').update(buf).digest('hex');
const current = {};
for (const file of await globby('dist/**/*', { dot: true })) current[file.slice(5)] = hash(await readFile(file));
const previous = JSON.parse(await s3GetText('s3://docs-bucket/.deploy-manifest.json').catch(() => '{}'));
const changed = Object.keys(current).filter((f) => current[f] !== previous[f]);
const removed = Object.keys(previous).filter((f) => !(f in current));
await writeFile('changed.txt', changed.join('\n'));
await writeFile('removed.txt', removed.join('\n'));
await writeFile('dist-manifest.json', JSON.stringify(current));

The manifest is stored in the bucket after each successful deploy. For a typical content change, changed.txt listed 6–12 files: the edited pages, pages whose navigation listed the edited page's title, the search index fragments and the sitemap.

Content-hash comparison between the new build and the last deploy The new build is hashed file by file. The manifest of the last successful deploy is downloaded from the bucket. Comparing them yields changed files, which are uploaded, and removed files, which are deleted after the upload. The new manifest is written last. For a typical content change, 9 of 9,400 files are uploaded. Working out what to upload Hash new build 9,400 files, 2.1 s Load last manifest from the bucket Compare changed and removed lists Upload changed typically 6 to 12 files Write manifest after success
SHA-256 of each file's bytes; timestamps and sizes are ignored.

Step 3: Upload in a Safe Order

Uploading changed files is not atomic by itself. A reader loading a page mid-deploy could receive new HTML that references a new hashed stylesheet not yet uploaded. The order prevents that:

  1. Upload changed non-HTML files (hashed assets, images, fonts, index fragments). They have new names, so nothing references them yet.
  2. Upload changed HTML, sitemap and feeds.
  3. Delete removed files — old hashed assets — after a delay, or leave them; old HTML still cached in browsers may reference them. See Keeping Old Assets Alive After Deploy.
  4. Write the new manifest.
grep -v '\.html$' changed.txt | xargs -I{} -P 16 aws s3 cp "dist/{}" "s3://docs-bucket/{}" --only-show-errors \
  --cache-control 'public, max-age=31536000, immutable'
grep '\.html$' changed.txt | xargs -I{} -P 16 aws s3 cp "dist/{}" "s3://docs-bucket/{}" --only-show-errors \
  --content-type 'text/html; charset=utf-8' --cache-control 'public, max-age=0, s-maxage=300, stale-while-revalidate=86400'
aws s3 cp dist-manifest.json s3://docs-bucket/.deploy-manifest.json

For a VM, rsync --checksum --delete-after gives the content comparison and deferred deletion in one command, and syncing into a new release directory followed by a symlink swap keeps it atomic, as in Zero-Downtime Deploys with Symlink Swaps. Copy the previous release with hard links (cp -al) first, so rsync only writes changed files.

Order of operations for a changed-files deploy Four steps in order. First, upload changed assets with new hashed names, which nothing references yet. Second, upload changed HTML, sitemap and feeds, which now reference assets that already exist. Third, purge only the changed HTML paths at the CDN. Fourth, delete removed files after a grace period and write the new manifest. Upload order that keeps readers safe 1. Changed assets new hashed names unreferenced until HTML changes 2. Changed HTML pages, sitemap, feeds references existing assets 3. Scoped purge changed HTML paths only e.g. 9 paths, not /* 4. Delete and manifest after grace period old assets live 7 days
At every moment during the deploy, every HTML page points only at files that exist.

Step 4: Purge Only What Changed

With the changed list in hand, the CDN purge covers only changed HTML paths — assets have new names and need no purge. A CloudFront invalidation of 9 paths instead of /* completes faster, and more importantly leaves every other page's edge cache warm. Directory-style URLs need both forms (/guide/install/ and /guide/install/index.html), depending on how the CDN caches them. CloudFront includes 1,000 invalidation paths per month free; scoped purges on this site used about 600 a month, where wildcard purges had counted as one path each but discarded the entire cache on every deploy.

When Everything Changes Anyway

Some changes legitimately touch most of the site: a template edit that alters every page's header, a CSS change that produces a new hashed stylesheet referenced by every page, a generator upgrade. The changed-files deploy handles these correctly — it simply uploads everything that changed — but the scoped purge becomes a list of thousands of paths. Above a threshold (500 changed HTML files on this site), the script switches to a wildcard purge, which is cheaper and simpler at that scale, and logs the reason. It also posts the changed-file count in the pull request's deploy summary, so reviewers of a "small" template change see that it touches 2,300 pages before it ships. That visibility caught one accidental site-wide change: a date format edited in a shared partial that had been intended for the blog only.

The manifest approach also gives a cheap audit trail. Keeping the last thirty manifests in the bucket under dated names made it possible to answer "when did this page last change in production?" without digging through CI logs, and to produce an exact list of files for any rollback.

Measured Impact

Median over 60 deploys before and after:

MeasureFull sync + /* purgeChanged files + scoped purge
Files uploaded per deploy9,400 (s3 sync re-uploaded ~8,900)9
Upload time3 min 12 s6 s
Total deploy time3 min 40 s21 s
HTML edge hit ratio in the hour after deploy61%94%
TTFB p75 in the hour after deploy280 ms115 ms
S3 PUT requests per month~560,000~2,400
Deploy time and hit ratio for full and changed-file deploys Paired bars. Total deploy time fell from 220 seconds with a full sync to 21 seconds uploading only changed files. The HTML edge cache hit ratio in the hour after a deploy rose from 61 percent with a wildcard purge to 94 percent with a scoped purge. Deploy time and post-deploy caching Full sync + wildcard purge Changed files + scoped purge Deploy time (s) 220 21 Post-deploy HTML hit ratio (%) 61 94
Medians over 60 deploys per method; hit ratio from CloudFront real-time logs.

Pitfalls & Rollback

  • Non-deterministic builds. Timestamps or random IDs make every file "changed"; fix them first.
  • Uploading HTML before assets. Readers can get pages that reference missing files.
  • Losing the manifest. If it is missing or corrupt, fall back to a full upload rather than trusting an empty comparison.
  • Deleting old assets immediately. Cached HTML still references them; keep a grace period.
  • Rollback: keep the previous manifest; a rollback deploy uploads the previous build's changed files the same way, or switch the release symlink or versioned prefix back.

Conclusion

Most static site deploys change a handful of files, and self-hosted deploys should upload a handful of files. Make the build deterministic, compare content hashes against the last deployed manifest, upload assets before HTML, delete late, and purge only changed paths. On this site deploys went from nearly four minutes to twenty seconds, and the edge cache stopped being thrown away on every change.

FAQ

Why deploy only changed files?

Large static sites produce thousands of files, but a typical content change alters a few HTML pages and perhaps a search index. Uploading everything wastes minutes and bandwidth, and makes CDN purges broader than needed. Uploading only changed files keeps deploys fast and purges targeted.

Do managed hosts already do this?

Yes. Netlify, Cloudflare Pages and Workers static assets, and Vercel compute a hash per file and upload only files the platform does not already have. The technique matters most for self-hosted targets such as S3, a VM with rsync, or any custom upload script.

Why does aws s3 sync upload unchanged files?

By default it compares size and modification time, and every build gives every file a new modification time. Files whose size did not change may be skipped, but most regenerated HTML is re-uploaded. Comparing content hashes instead avoids that.

How do I keep a changed-files deploy atomic?

Upload new and changed assets first, then HTML, then delete removed files last, or deploy to a new versioned prefix and switch a pointer. Readers should never receive HTML that references an asset that has not been uploaded yet.

Filed underHow-tosAWS (S3 and CloudFront)