GitHub Actions Hash Manifest and Atomic CDN Deploy
Every frontend team eventually hits the same wall: a deploy that ships HTML referencing chunk filenames that do not yet exist on the CDN. Users land on a stale cached page, the browser fetches a new HTML document pointing to main.a1b2c3d4.js, and gets a 404 because the asset upload is still running. The page breaks for seconds or minutes depending on CDN propagation speed.
The root cause is ordering. When your CI/CD pipeline uploads HTML and assets in a single undifferentiated aws s3 sync, both operations race to the same S3 bucket with no guarantee that assets arrive before the HTML that references them. Fixing this requires separating the pipeline into jobs with explicit ordering: assets upload first with immutable cache headers, HTML uploads last, and a targeted CloudFront invalidation fires only after HTML is confirmed live.
This page covers the complete workflow — from building a content-hash-based manifest with Vite through multi-job GitHub Actions orchestration to atomic S3 sync and a single index.html CloudFront invalidation. For foundational concepts on why deterministic build outputs matter before this pipeline even starts, see that reference first.
Why Deploying HTML Before Assets Causes 404s
When a user’s browser holds a cached copy of index.html and a new deploy begins, several race conditions are possible:
Race 1 — CDN serves fresh HTML, assets not yet uploaded. The CDN invalidation runs before assets finish uploading. The browser gets new HTML, requests chunk.a1b2c3d4.js, but S3 returns 404 because the upload job is still running.
Race 2 — Single sync uploads HTML before assets. aws s3 sync dist/ s3://bucket/ processes files in filesystem order. On large projects, index.html alphabetically precedes most asset paths and often uploads first.
Race 3 — Old HTML + new assets. Less harmful but still possible: a CDN edge still serving cached old index.html that references a chunk hash that no longer exists after a subsequent deploy pruned it.
The correct ordering guarantee is:
- All versioned assets (JS, CSS, fonts, images) are present in S3 before any HTML changes become visible.
- HTML goes live only after assets are confirmed uploaded.
- CloudFront invalidation fires only after HTML is in S3.
GitHub Actions needs: keys enforce this ordering across jobs, making it structural rather than accidental.
Anatomy of the Three-Job Pipeline
The pipeline splits into three sequential jobs with explicit needs: dependencies:
| Job | Inputs | Outputs | Cache-Control header |
|---|---|---|---|
build |
source code | dist/ directory artifact |
— |
upload-assets |
dist/ artifact |
assets in S3, manifest.json artifact |
public,max-age=31536000,immutable |
deploy-html |
manifest.json artifact |
index.html in S3 + CloudFront invalidation |
no-cache,must-revalidate |
Why separate manifest.json as an artifact? The Vite build emits .vite/manifest.json mapping source module paths to their hashed filenames. Passing this between jobs as an actions/upload-artifact@v4 artifact means the deploy-html job can verify the expected chunks are present in S3 before it touches HTML. It also feeds rollback workflows that need to know which hash was live at a given commit.
Why no-cache,must-revalidate on HTML but immutable on assets? Assets are content-addressed — main.a1b2c3d4.js will never change because the hash encodes the content. Serving them with max-age=31536000,immutable tells every CDN edge and browser to cache them forever. HTML is not content-addressed; it’s the entry point that must always reflect the latest deploy. no-cache forces a revalidation on every request. See the Vite asset pipeline configuration reference for how build.manifest: true generates the hash-to-filename map. Webpack users can find equivalent setup at the webpack output hashing reference.
Decision Matrix: When Atomic Ordering Matters
| Scenario | Atomic ordering required? | Notes |
|---|---|---|
| SPA with code splitting | Yes | Multiple chunk files; stale HTML references missing hashes |
| Single-file bundle (no splitting) | Yes, but lower risk | Still worth enforcing; costs nothing |
| SSR app with client hydration | Yes, critical | Hydration mismatch on version skew causes runtime errors |
| Static site, no JS | No | HTML-only deploys with no versioned assets |
| Blue/green with traffic shifting | Partially | Traffic shift replaces ordering concern, but assets still need to pre-exist |
| Monorepo with shared chunks | Yes, elevated | Cross-package chunk sharing means a single missing hash breaks multiple entry points |
For monorepos or large projects with many shared chunks, consider extending hash length to 12–16 hex characters to reduce collision probability across packages. The 8-character default used in examples here is appropriate for single-app projects.
Pipeline Flow Diagram
dist/ and manifest.json between jobs; needs: keys prevent any job from starting until its predecessor completes.Complete GitHub Actions Workflow
The workflow below is self-contained and production-ready. It targets a Vite project deploying to S3 + CloudFront, but the structure applies equally to webpack-based pipelines by swapping the build command.
Store AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET, and CF_DIST_ID as GitHub Actions repository secrets before running.
name: Atomic CDN Deploy
on:
push:
branches:
- main
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build:
name: Build
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NODE_ENV: production
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1
upload-assets:
name: Upload Assets
needs: build
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: read
steps:
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Sync versioned assets with immutable cache headers
run: |
aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }}/ \
--exclude "*.html" \
--cache-control "public,max-age=31536000,immutable" \
--delete \
--no-progress
- name: Upload index.html with no-cache headers (pre-stage, not yet live)
run: |
aws s3 cp dist/index.html s3://${{ secrets.S3_BUCKET }}/index.html.next \
--cache-control "no-cache,must-revalidate" \
--content-type "text/html; charset=utf-8"
- name: Upload Vite manifest as pipeline artifact
uses: actions/upload-artifact@v4
with:
name: vite-manifest
path: dist/.vite/manifest.json
retention-days: 7
deploy-html:
name: Deploy HTML
needs: upload-assets
runs-on: ubuntu-24.04
environment: production
steps:
- name: Download Vite manifest artifact
uses: actions/download-artifact@v4
with:
name: vite-manifest
path: manifest/
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Promote index.html to live path
run: |
aws s3 cp s3://${{ secrets.S3_BUCKET }}/index.html.next \
s3://${{ secrets.S3_BUCKET }}/index.html \
--cache-control "no-cache,must-revalidate" \
--content-type "text/html; charset=utf-8"
- name: Invalidate CloudFront index.html
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DIST_ID }} \
--paths "/index.html"
- name: Clean up staged file
run: |
aws s3 rm s3://${{ secrets.S3_BUCKET }}/index.html.next
Key decisions in this workflow
concurrency: cancel-in-progress: false — The group: deploy-production key prevents two deploys from running simultaneously. Setting cancel-in-progress: false (rather than true) ensures an in-flight deploy finishes before the next one starts. Cancelling mid-deploy would leave HTML and assets in a partially updated state. If you need the latest commit to always win immediately, switch to cancel-in-progress: true and accept the risk of a brief asset gap on the cancelled run.
--delete on asset sync only — The --delete flag removes files from S3 that are no longer in dist/. It runs only on the asset sync, not on HTML. Pruning old hashed assets is safe because any browser still holding a reference to them has that reference in a cached HTML document that will soon be invalidated. Assets from the immediately prior deploy should remain long enough for in-flight requests to complete; if you have high traffic, remove --delete and run a separate cleanup Lambda on a delay.
index.html.next staging pattern — Rather than uploading index.html directly in upload-assets and risking it going live before CloudFront propagation, the workflow stages it as index.html.next, then atomically promotes it in deploy-html. This is belt-and-suspenders: the needs: ordering already guarantees assets are present, but the staging pattern adds an explicit moment where the operator could inspect the staged file before promotion via the environment: production GitHub Environments gate.
retention-days: 1 for dist, 7 for manifest — The dist/ artifact is large and only needed to pass between jobs in the same run. The manifest artifact is small and useful for post-deploy debugging and rollback workflows.
Why a Single /index.html Invalidation Is Enough
A common instinct is to invalidate /* on every deploy to guarantee no stale content. This is expensive, slow, and unnecessary when assets are content-addressed.
Hashed assets like main.a1b2c3d4.js never change. CloudFront can and should serve them from cache indefinitely — that is the point of max-age=31536000,immutable. Invalidating them wastes CloudFront invalidation quota (the first 1,000 paths per month are free; wildcard /* counts as one path but triggers full-distribution propagation that can take 5–15 minutes) and forces every CDN edge to re-fetch assets from S3 on the next request.
The only file that changes on deploy is index.html. Invalidating /index.html is sufficient because:
- All asset filenames in the new
index.htmlare new hashes not yet cached by any browser. - Browsers that hold a cached copy of the old
index.htmlwill revalidate it on the next navigation because ofno-cache,must-revalidate. - CloudFront edges that served the old
index.htmlwill fetch the new one after the targeted invalidation completes (typically under 60 seconds).
For CloudFront invalidation mechanics, propagation timing, and cost modeling, see the AWS CloudFront invalidation reference. For Subresource Integrity validation of hashed assets, which adds an additional integrity check beyond filename-based content addressing, see that guide.
Multi-page apps (MPA) with several HTML entry points require one invalidation path per entry: /index.html, /about/index.html, /dashboard/index.html. Still cheaper than /* and still avoids invalidating immutable assets.
Verification Command
After the deploy-html job completes, confirm the CloudFront invalidation has propagated and that cache headers are correct:
# Replace with your actual CloudFront domain and distribution ID
CF_DOMAIN="d1abc2def3gh4i.cloudfront.net"
CF_DIST_ID="E1ABCDEFGHIJKL"
# Check index.html cache headers (should show no-cache)
curl -sI "https://${CF_DOMAIN}/index.html" | grep -i "cache-control\|x-cache\|age"
# Check a hashed asset (should show max-age=31536000 and x-cache: Hit)
curl -sI "https://${CF_DOMAIN}/assets/main-a1b2c3d4.js" | grep -i "cache-control\|x-cache\|age"
# Confirm the invalidation completed (status should be Completed, not InProgress)
aws cloudfront get-invalidation \
--distribution-id "${CF_DIST_ID}" \
--id "$(aws cloudfront list-invalidations \
--distribution-id "${CF_DIST_ID}" \
--query 'InvalidationList.Items[0].Id' \
--output text)"
Expected output for index.html: cache-control: no-cache, must-revalidate and x-cache: Miss from cloudfront (confirming the edge fetched fresh content after invalidation). Expected output for a hashed asset: cache-control: public, max-age=31536000, immutable and x-cache: Hit from cloudfront after the first request.
When to Reconsider This Approach
When you have a blue/green deployment setup. If your infrastructure supports traffic shifting at the load balancer or CDN origin group level, you can deploy the new version to a shadow origin and shift traffic atomically. This eliminates the asset-before-HTML ordering problem entirely at the cost of running two origin environments simultaneously.
When your app uses server-side rendering with version-pinned API responses. In SSR setups where the HTML is generated at request time by a server that also controls the asset manifest, the static file ordering problem does not apply in the same way. The server guarantees HTML and assets are consistent within a single server version.
When your CDN supports origin shield and you can tolerate brief stale HTML. Some teams accept a 30–60 second window of potential version skew if their error monitoring shows no impact and their user base is not latency-sensitive. This is a risk tolerance decision, not a technical recommendation.
When your asset graph is shallow (single bundle, no code splitting). A single-file bundle with a fixed filename like bundle.a1b2c3d4.js still benefits from ordered deployment, but the blast radius of a race condition is lower — there are no inter-chunk dependencies to violate. The workflow overhead may not be justified for very small projects.
When you need to support many HTML entry points with different invalidation schedules. Projects with 50+ HTML routes may benefit from a CDN-level versioning approach — appending a deploy ID as a query parameter to HTML files and updating a routing rule — rather than issuing 50+ individual CloudFront invalidations per deploy.
Related
- CI/CD Asset Pipeline Integration — parent section covering CI/CD pipeline patterns for fingerprinted assets
- AWS CloudFront Invalidation — invalidation mechanics, propagation timing, cost, and wildcard vs. path-targeted strategies
- Rolling Back Fingerprinted Assets in CI/CD — how to use the
vite-manifestartifact from this workflow to roll back to a prior content hash - Deterministic Build Outputs — why the same source must always produce the same hash, and how to audit your build for non-determinism before wiring up this pipeline