Astro, Vite, Next.js (static export), and similar generators produce two kinds of files: HTML pages and hashed assets. The hashed assets have content hashes in their filenames (like _app.D4f8k2.js), so a new build produces a new filename. HTML pages keep the same filenames across builds. This distinction is the key to the caching strategy.
Security Headers Policy
The security headers policy is similar to the static site version from post 12, but the Content-Security-Policy needs adjustments for how static generators work.
SecurityHeadersPolicy:
Type: 'AWS::CloudFront::ResponseHeadersPolicy'
Properties:
ResponseHeadersPolicyConfig:
Name: !Sub '${AWS::StackName}-SecurityHeaders'
SecurityHeadersConfig:
StrictTransportSecurity:
AccessControlMaxAgeSec: 31536000
IncludeSubdomains: true
Preload: true
Override: true
ContentTypeOptions:
Override: true
FrameOptions:
FrameOption: DENY
Override: true
ReferrerPolicy:
ReferrerPolicy: strict-origin-when-cross-origin
Override: true
XSSProtection:
Protection: true
ModeBlock: true
Override: true
ContentSecurityPolicy:
Override: true
ContentSecurityPolicy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
StrictTransportSecuritysends theStrict-Transport-Securityheader. It tells browsers to only connect over HTTPS for one year (31536000seconds), includes all subdomains, and opts into the browser preload list so the rule applies even on the very first visit.ContentTypeOptionssendsX-Content-Type-Options: nosniff. It prevents browsers from guessing a file’s MIME type, which stops attacks where a malicious file is disguised as something harmless.FrameOptions: DENYsendsX-Frame-Options: DENY. It blocks other sites from embedding your pages in an<iframe>, preventing clickjacking attacks.ReferrerPolicy: strict-origin-when-cross-originsends the full URL as a referrer for same-origin requests but only the origin (no path) for cross-origin requests. This prevents leaking page paths to third-party sites.XSSProtectionsendsX-XSS-Protection: 1; mode=block. It enables the browser’s legacy XSS filter. Modern browsers rely on CSP instead, but this header provides a fallback for older browsers.
Two differences from the plain static site CSP:
style-src 'self' 'unsafe-inline' allows inline styles. Astro injects scoped styles directly into the HTML for view transitions and component-level styling. Without 'unsafe-inline', those styles get blocked and the page renders without them.
img-src 'self' data: https: and font-src 'self' data: allow data: URIs and external HTTPS sources. Static generators often inline small images as base64 data URIs for performance, and may reference external image CDNs. The data: scheme covers both cases.
Attach the policy to your CloudFront distribution the same way as post 12, by adding ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy to the DefaultCacheBehavior.
Deploy Service Role
The deploy role is identical to the one in post 12. It needs CloudWatch Logs, artifact bucket read, site bucket read/write/delete, and CloudFront invalidation permissions. Refer to post 12 for the full YAML.
Deploy Project
This is where the Astro deploy differs from a plain static site. Instead of one s3 sync with a uniform cache header, you run two syncs with different cache strategies.
DeployProject:
Type: 'AWS::CodeBuild::Project'
Properties:
Name: !Sub '${AWS::StackName}-Deploy'
ServiceRole: !GetAtt DeployServiceRole.Arn
Artifacts:
Type: CODEPIPELINE
Environment:
Type: LINUX_CONTAINER
ComputeType: BUILD_GENERAL1_SMALL
Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0
EnvironmentVariables:
- Name: DEPLOY_BUCKET
Value: !Ref S3WebsiteBucket
- Name: CLOUDFRONT_DIST_ID
Value: !Ref CloudFrontDistribution
Source:
Type: CODEPIPELINE
BuildSpec: |
version: 0.2
phases:
build:
commands:
- aws s3 sync . s3://$DEPLOY_BUCKET --delete --cache-control "max-age=31536000,public,immutable" --exclude "*.html"
- aws s3 sync . s3://$DEPLOY_BUCKET --delete --cache-control "max-age=0,no-cache,no-store,must-revalidate" --exclude "*" --include "*.html"
post_build:
commands:
- aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DIST_ID --paths "/*"
The two s3 sync commands create a two-tier caching strategy:
The first sync uploads everything except HTML files with max-age=31536000,public,immutable. That’s one year. The immutable directive tells the browser to never revalidate these files. This is safe because hashed filenames change on every build, so a new deploy naturally points to new URLs. Old cached versions become unreachable. The --delete flag removes stale files from S3 that no longer exist in the build output.
The second sync uploads only HTML files with max-age=0,no-cache,no-store,must-revalidate. HTML pages always have the same filenames (/about/index.html, /notes/index.html), so the browser must check for updates on every request. The HTML references the hashed asset URLs, so when a new build changes an asset, the HTML points to the new hash and the browser fetches it fresh. The --delete flag here removes HTML files from S3 for pages that no longer exist in the build output. Because of the --exclude "*" --include "*.html" filters, --delete only considers HTML files. Non-HTML files are left untouched by this command.
This pattern is the standard approach for any framework that produces hashed filenames. The HTML acts as the entry point that ties everything together.
Subdirectory Index Rewriting
Astro generates a separate HTML file for every route: /about/index.html, /blog/index.html, and so on. But CloudFront’s DefaultRootObject only rewrites the root path / to index.html. It does not handle subdirectory requests. A request to /blog/ hits S3 looking for the key blog/, which does not exist, and S3 returns a 403.
A CloudFront Function fixes this by rewriting URIs before they reach S3.
SubdirectoryIndexFunction:
Type: 'AWS::CloudFront::Function'
Properties:
AutoPublish: true
Name: !Sub '${AWS::StackName}-IndexRewrite'
FunctionConfig:
Comment: 'Appends index.html to subdirectory requests'
Runtime: cloudfront-js-1.0
FunctionCode: |
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
The function runs on every viewer-request. If the URI ends with /, it appends index.html. If the URI has no file extension (no .), it appends /index.html. Requests for actual files like /styles.css or /_app.D4f8k2.js pass through unchanged.
Attach the function to your distribution’s DefaultCacheBehavior from post 7 using FunctionAssociations.
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # AWS Managed CachingOptimized policy
ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy
Compress: true
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !GetAtt SubdirectoryIndexFunction.FunctionARN
The CloudFront Function handles all the routing that S3 cannot. Unlike a React SPA, there is no client-side router, so you do not need CustomErrorResponses to rewrite errors to index.html. But you do need one for a proper 404 page.
Custom 404 Page
Astro generates a /404.html page by default. But when someone visits a path that does not exist (like /doesnotexist), the SubdirectoryIndexFunction rewrites it to /doesnotexist/index.html. That key does not exist in S3, and because the bucket is private, S3 returns a 403. Without a custom error response, CloudFront shows its default error page.
Add CustomErrorResponses to the DistributionConfig in your CloudFront distribution from post 7.
CustomErrorResponses:
- ErrorCode: 403
ResponseCode: 404
ResponsePagePath: /404.html
This catches the 403 from S3 and serves Astro’s generated /404.html with a proper 404 status code. This is different from the React SPA pattern in post 14, which returns a 200 and serves index.html so the client-side router can handle the path. Here, the 404 is a real 404 because there is no client-side router. The page genuinely does not exist.
This does not conflict with the SubdirectoryIndexFunction. Valid routes resolve to real S3 keys and never trigger an error. Only genuinely missing pages produce a 403, which this rule catches.
Updating the Role
Your Git Sync role needs permissions for CloudFront Functions so CloudFormation can create and manage them. Add this statement to the inline policy in your role stack.
- Effect: Allow
Action:
- 'cloudfront:CreateFunction'
- 'cloudfront:GetFunction'
- 'cloudfront:UpdateFunction'
- 'cloudfront:DeleteFunction'
- 'cloudfront:PublishFunction'
- 'cloudfront:DescribeFunction'
Resource: '*'
Deploy Stage
The pipeline stage configuration is identical to post 12. Add the Deploy stage to the pipeline’s Stages array with Provider: CodeBuild pointing to the deploy project. Refer to post 12 for the full YAML.