The deployment finished, the new file reached S3, and the page is still serving the previous version. The usual reaction is to invalidate /*, confirm that things work again, and make that wildcard a permanent part of the pipeline. It “solves caching” on every deployment in much the same way that switching off the breaker solves a flickering light.
The result we want is more predictable: a private bucket, access only through CloudFront with Origin Access Control (OAC), versioned assets with long-lived caching, HTML with short-lived caching, and targeted invalidation when needed. By the end, you will be able to inspect the attached policy, submit an invalidation, track its status, and prove that S3 was not made public.
Prerequisites and production design
You need an authenticated AWS CLI with permission to inspect the distribution, create invalidations, and read the bucket configuration. In the commands, replace DISTRIBUTION_ID, BUCKET, REGION, ACCOUNT, and the dXXXX.cloudfront.net domain with values from your account.
The production design looks like this:
Navegador -> CloudFront -> origem REST do S3 privado
-> OAC assina a requisição com SigV4
Do not use the S3 static website endpoint as the origin. It is treated as a custom origin, and OAC cannot be associated with it. Do not open the bucket to compensate for an incomplete policy either. The documentation on restricting access to S3 origins recommends OAC; Origin Access Identity (OAI) is the legacy mechanism.
For OAC, keep Block Public Access enabled, use Bucket owner enforced for Object Ownership, and set signing to always. The minimal read policy restricts the service principal to your distribution ARN:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::BUCKET/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT:distribution/DISTRIBUTION_ID"
}
}
}
]
}
When migrating from OAI to OAC, temporarily allow both principals, associate the OAC with the distribution, and only then remove the OAI statement. That order keeps access available during the switch without weakening security.
Cache policy and Cache-Control solve different parts
A CloudFront cache policy defines the cache key and the edge retention limits: MinTTL, DefaultTTL, and MaxTTL. The Cache-Control header comes from the origin and states how long that object can be reused. The two work together; there is no “official Spring Boot 4.1 cache policy.”
Two managed policies help make the difference clear:
| Policy | ID | MinTTL | DefaultTTL | Primary use |
|---|---|---|---|---|
| UseOriginCacheControlHeaders | 83da9c7e-98b4-4e11-a168-04f0df8e2c65 | 0 | 0 | Let origin headers control the TTL |
| CachingOptimized | 658327ea-f89d-4fab-a63d-7e88639e58f6 | 1 second | 86400 seconds | Optimized general caching when that minimum is acceptable |
With MinTTL=0, Cache-Control: max-age=300 allows up to 300 seconds in CloudFront, subject to MaxTTL. If s-maxage is present, it defines CloudFront's shared TTL, while the browser continues to use max-age.
With a MinTTL greater than zero, CloudFront holds the object for at least that amount of time, even when the origin sends no-cache, no-store, or private. That is why CachingOptimized, whose MinTTL is 1 second, is not the same as fully obeying the origin. And if the S3 object has neither Cache-Control nor Expires, DefaultTTL takes over: 24 hours with this policy. The object without metadata looks harmless until it survives the next deployment. Then it develops a personality.
The CloudFront expiration documentation explains this interaction in detail. It also covers stale-while-revalidate: CloudFront can serve an expired copy while fetching the new one in the background. However, the stale-content window is limited by the policy's MaxTTL. A long directive in the header does not cross that ceiling.
For a site generated and stored in S3, a practical split is:
- assets with a hash in the filename, such as
/assets/app.a83f1c.js:Cache-Control: public, max-age=31536000, immutable; - HTML, such as
/index.html: a shortCache-Controland targeted invalidation when the deployment needs an immediate switch; - a policy with
MinTTL=0when the requirement is to let these headers control freshness.
You can apply metadata while uploading to S3. Keep the groups separate so that HTML does not receive a year-long cache:
aws s3 sync dist/assets/ s3://BUCKET/assets/ \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html s3://BUCKET/index.html \
--cache-control "public,max-age=60"
If the origin is a Spring Boot application, use the headers it emits as part of the same strategy. Spring Boot 4.1.1 with Java 25 LTS is a supported combination according to the Spring Boot 4.1 system requirements; these properties configure caching and hashed URLs in the application, not a CloudFront policy:
spring.web.resources.cache.cachecontrol.max-age=1h
spring.web.resources.cache.cachecontrol.s-max-age=10m
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**
Invalidate what changed, not the distribution out of habit
The invalidation API requires paths that begin with /. They are case-sensitive, and * works as a wildcard only when it is the last character. Therefore, /assets/* is valid as a tree; an asterisk in the middle of the path is treated literally. In the shell, always quote "/*" to prevent local expansion.
Prefer invalidating the HTML that references the new assets:
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id DISTRIBUTION_ID \
--paths "/index.html" \
--query 'Invalidation.Id' \
--output text)
aws cloudfront get-invalidation \
--distribution-id DISTRIBUTION_ID \
--id "$INVALIDATION_ID" \
--query 'Invalidation.Status' \
--output text
The status reaches Completed when the invalidation finishes. If clearing everything is genuinely necessary, the command is explicit:
aws cloudfront create-invalidation \
--distribution-id DISTRIBUTION_ID \
--paths "/*"
The first block of 1,000 invalidation paths per month is free per AWS account, summed across all distributions. After that, additional paths are charged individually. A /* counts as one path even if it reaches many objects; /index.html and /logo.svg count as two. Check the current CloudFront pricing page before estimating costs because rates can change.
There is another important limitation: invalidating CloudFront does not clear the browser cache. An asset with a stable URL and a long max-age can remain stale on the client. A hash in the filename changes the URL and avoids this conflict; it is the preferred choice for files that change frequently.
Verify the configuration with your resources
Start by finding which policy is attached to the default behavior and inspect its TTLs:
POLICY_ID=$(aws cloudfront get-distribution-config \
--id DISTRIBUTION_ID \
--query 'DistributionConfig.DefaultCacheBehavior.CachePolicyId' \
--output text)
aws cloudfront get-cache-policy \
--id "$POLICY_ID" \
--query 'CachePolicy.CachePolicyConfig.{Name:Name,MinTTL:MinTTL,DefaultTTL:DefaultTTL,MaxTTL:MaxTTL}'
Then check the headers that are actually delivered. Cache-Control shows the directive sent to the client, and Age helps you see how long the response has been in the shared cache, when present:
curl -sI https://dXXXX.cloudfront.net/index.html
Finally, prove the access boundary. The object's direct REST URL should return 403, while the same object through CloudFront should return 200:
aws s3api get-public-access-block --bucket BUCKET
aws s3api get-bucket-policy-status --bucket BUCKET
curl -sS -o /dev/null -w '%{http_code}\n' \
https://BUCKET.s3.REGION.amazonaws.com/index.html
curl -sS -o /dev/null -w '%{http_code}\n' \
https://dXXXX.cloudfront.net/index.html
If S3 returns 200, stop and fix the exposure before discussing TTLs. If CloudFront returns 403, review the REST origin, the OAC association, the signing mode, and the bucket policy's AWS:SourceArn.
Pitfalls worth checking during deployment
- The S3 website endpoint does not support OAC; use the bucket's REST origin.
CachingOptimizedhasMinTTL=1andDefaultTTL=86400; an object withoutCache-Controlcan remain at the edge for a day.- An origin
no-storedoes not beat a policy whoseMinTTLis greater than zero. - Invalidations cannot be canceled after submission.
- Assets without hashes require coordination among the browser cache, edge, and origin.
- Origin Shield is an optional layer with an additional charge, useful in specific scenarios; it is not required to fix this strategy.
As an additional financial safeguard, you can create a monthly AWS Budget and tag the distribution with a cost-allocation tag such as project. The tag must be activated for cost allocation before it appears in billing data, and current-month data can take about 24 hours to reflect that activation. Tag the distribution, not the invalidation: invalidations do not accept tags. This alarm is useful, but it does not replace correct headers and paths.
Next step
Choose a test deployment, add Cache-Control to the objects, attach a policy compatible with those headers, and replace /* with the list of changed HTML files. Run the verification commands before and after publishing, and record the policy and paths in the pipeline.
The recommendation is firm: if assets receive hashed names, keep a long cache on them and invalidate only HTML; use /* only when you can explain why the entire distribution must be discarded in that deployment. If the explanation is “we have always done it this way,” the wildcard has already become accidental configuration.