The problem
You deploy a new version of your Angular app. The JS and CSS files have new hashes in their names. But index.html is still index.html. And CloudFront, being the good CDN it is, happily caches it. Result: users keep seeing the old version until the TTL expires.
The difference today: the way to solve it has changed. And there are better ways than putting Cache-Control meta tags in HTML (yes, that was an antipattern that should never have existed).
Modern Angular: what changed since 2020
The build no longer uses --prod
In Angular v19 (and since v12, actually), --prod disappeared. Now you use environment configurations:
# Before (2020)
ng build --prod
# Now (2026)
ng build
By default, Angular CLI already generates production builds with:
- Output hashing automatically enabled —
main.4cd54d2a590c84799c74.js - Tree shaking and minification — no configuration needed
- Standalone components — no more
NgModuleboilerplate
# Your dist/ looks like this in 2026
Initial chunk files | Names | Raw size | Estimated transfer size
main-XYZABC.js | main | 185.45 kB | 51.23 kB
polyfills-XYZABC.js | polyfills | 34.21 kB | 11.04 kB
styles-XYZABC.css | styles | 12.88 kB | 2.15 kB
| Initial total | 232.54 kB | 64.42 kB
Note: Notice that
polyfills-es5no longer exists. Angular dropped IE11 support years ago. If you still need to support it, you have bigger problems than cache busting.
Hashes are immutable (and that’s good)
Angular generates hashes based on content. If your code doesn’t change, the hash doesn’t change. This means browsers can cache unchanged chunks indefinitely — a huge performance win.
# Deploy 1
main.a1b2c3d.js → cache 1 year
# Deploy 2 (only one component changed)
main.e4f5g6h.js → cache 1 year (new file)
vendor.a1b2c3d.js → cache 1 year (same hash, still in browser cache)
Modern AWS: CloudFront Cache Policies
You no longer manually edit TTLs on each behavior. Today you use reusable Cache Policies.
Cache Policy for index.html (DO NOT cache)
- CloudFront → Policies → Cache → Create cache policy
| Setting | Value |
|---|---|
| Name | AngularIndexNoCache |
| Minimum TTL | 0 |
| Maximum TTL | 0 |
| Default TTL | 0 |
| Headers in cache key | None |
- Open your distribution → Behaviors → Edit the default
* - In Cache policy, select
AngularIndexNoCache
Why TTL = 0? index.html is your entry point. It should never be cached. It always goes to the origin so users receive the latest version.
Cache Policy for assets (cache 1 year)
| Setting | Value |
|---|---|
| Name | AngularAssetsImmutable |
| Minimum TTL | 31536000 |
| Maximum TTL | 31536000 |
| Default TTL | 31536000 |
Associate it with a behavior with path pattern *.js, *.css, *.woff2.
The logic: the hashes in the filenames are the cache busting. Content changes → name changes → new file. If the name is the same, the content is identical. Cache for a year without fear.
Origin Access Control (OAC): replacing OAI
OAI is deprecated. Use OAC (Origin Access Control).
- CloudFront → Origins → Edit your S3 origin
- In Origin access, select Origin access control settings (recommended)
- Create a new OAC → CloudFront gives you a policy to paste into S3
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontOAC",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-angular-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789:distribution/DISTRIBUTION_ID"
}
}
}]
}
OAC vs OAI: supports SSE-KMS, doesn’t use legacy IAM identity, is the AWS-recommended way today.
Do NOT use cache-control meta tags
Some still suggest this:
<!-- ❌ DON'T DO THIS -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
This doesn’t work. Modern browsers ignore those meta tags for static resources, and CloudFront doesn’t read them. Real control is in HTTP headers.
Correct headers when uploading to S3
# index.html → DO NOT cache
aws s3 cp dist/browser/index.html s3://your-bucket/ \
--cache-control "no-cache, no-store, must-revalidate"
# Hashed assets → cache 1 year
aws s3 sync dist/browser/ s3://your-bucket/ \
--exclude "index.html" \
--cache-control "public, max-age=31536000, immutable"
S3 translates --cache-control to HTTP headers that CloudFront respects.
CI/CD with GitHub Actions
# .github/workflows/deploy.yml
name: Deploy Angular to AWS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsDeployRole
aws-region: us-east-1
# 1. Clean non-hashed files
- run: |
aws s3 rm s3://your-bucket/index.html || true
aws s3 rm s3://your-bucket/assets/ --recursive || true
# 2. Upload hashed assets with long cache
- run: |
aws s3 sync dist/browser/ s3://your-bucket/ \
--exclude "index.html" \
--cache-control "public, max-age=31536000, immutable"
# 3. Upload index.html WITHOUT cache
- run: |
aws s3 cp dist/browser/index.html s3://your-bucket/ \
--cache-control "no-cache, no-store, must-revalidate"
# 4. Invalidate only index.html
- run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
--paths "/index.html" "/"
Why invalidate only index.html?
Hashed assets are new files — CloudFront has never seen them, so they go straight to origin. You only invalidate index.html because it’s the same path with new content.
OIDC: no hardcoded credentials
Notice there’s no AWS_ACCESS_KEY_ID. GitHub Actions authenticates via OIDC with AWS IAM:
- IAM → Identity providers → Add → OpenID Connect
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com - Create a role with trust policy for
repo:your-org/your-repo:*
More secure. More clean. No secrets in the repo.
Final Checklist: Cache Busting in 2026
| # | Check | Status |
|---|---|---|
| 1 | Angular build generates hashes in production | ✅ Automatic since v12+ |
| 2 | index.html has TTL = 0 in CloudFront | ✅ Cache Policy |
| 3 | Hashed assets have TTL = 1 year | ✅ Separate Cache Policy |
| 4 | S3 has correct HTTP headers | ✅ aws s3 cp --cache-control |
| 5 | OAC protects bucket (not legacy OAI) | ✅ Origin Access Control |
| 6 | CI/CD automates deploy + invalidation | ✅ GitHub Actions |
| 7 | OIDC instead of hardcoded credentials | ✅ configure-aws-credentials@v4 |
Conclusion
The cache busting problem still exists, but the tools to solve it have improved dramatically. In 2026, the solution is not an HTML meta tag hack — it’s a combination of:
- Angular CLI generating immutable assets with automatic hashes
- CloudFront Cache Policies managing TTLs declaratively and reusably
- HTTP Headers controlling cache at the protocol level
- CI/CD automating deploy and invalidation
- OAC + OIDC securing everything without hardcoded credentials
The key is understanding that cache busting is not an Angular problem — it’s a deploy architecture problem. Angular already does its part with output hashing. The rest depends on how you configure your CDN and pipeline.
If you’re still doing manual deploys to S3 or using OAI, it’s time to update. The ecosystem evolved. So can you.