Back to notes

The Build stage produced an artifact. Now the Deploy stage needs to get those files into S3 with proper cache headers, invalidate CloudFront, and add security headers. This post covers plain HTML/CSS/JS sites where filenames don’t change between deploys.

Security Headers Policy

Browsers enforce security features through HTTP response headers. CloudFront ResponseHeadersPolicies let you attach these headers at the CDN layer without modifying your application code.

  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'; img-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"

Each header serves a specific purpose:

StrictTransportSecurity (HSTS) tells browsers to only connect over HTTPS for one year. IncludeSubdomains extends this to all subdomains. Preload lets you submit the domain to browser HSTS preload lists so the first visit is also HTTPS.

ContentTypeOptions sends X-Content-Type-Options: nosniff, preventing browsers from guessing file types. A CSS file stays a CSS file, even if the content looks like JavaScript.

FrameOptions: DENY prevents your site from being embedded in iframes. This blocks clickjacking attacks where an attacker overlays your site with invisible elements.

ReferrerPolicy: strict-origin-when-cross-origin sends the full URL as a referrer for same-origin requests but only the origin (no path) for cross-origin requests. This prevents leaking internal URL paths to third-party services.

XSSProtection enables the browser’s built-in XSS filter in block mode. Modern browsers handle XSS through CSP, but this provides defense in depth for older browsers.

ContentSecurityPolicy is the most important header. It restricts where the browser can load resources from. For a plain static site, 'self' on every directive means the browser only loads scripts, styles, images, and fonts from your own domain. frame-ancestors 'none' duplicates the iframe protection from FrameOptions. base-uri 'self' prevents <base> tag injection. form-action 'self' restricts form submissions to your domain.

Attach the policy to your CloudFront distribution by adding ResponseHeadersPolicyId to the DefaultCacheBehavior from post 7.

        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # AWS Managed CachingOptimized policy
          ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy
          Compress: true

Deploy Service Role

The deploy project needs its own IAM role with permissions to read artifacts, write to the site bucket, and invalidate CloudFront.

  DeployServiceRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: 'sts:AssumeRole'
      Policies:
        - PolicyName: DeployAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/${AWS::StackName}-Deploy*'
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:GetBucketVersioning
                Resource:
                  - !Sub '${PipelineArtifactBucket.Arn}/*'
                  - !GetAtt PipelineArtifactBucket.Arn
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                  - s3:DeleteObject
                  - s3:ListBucket
                Resource:
                  - !GetAtt S3WebsiteBucket.Arn
                  - !Sub '${S3WebsiteBucket.Arn}/*'
              - Effect: Allow
                Action:
                  - 'cloudfront:CreateInvalidation'
                Resource: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}'

This role has four statements. CloudWatch Logs for build output. Artifact bucket read so CodeBuild can download the build artifact. Site bucket read/write/delete so it can sync files and remove stale ones. CloudFront invalidation so it can clear the edge cache after deploy.

Deploy Project

The DeployProject uses CodeBuild to run two aws s3 sync commands with different Cache-Control headers. Static sites without hashed filenames need a two-tier approach: long-lived caching for assets and immediate revalidation for HTML.

  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 --exclude "*.html" --delete --cache-control "max-age=31536000,public,immutable"
                - aws s3 sync . s3://$DEPLOY_BUCKET --exclude "*" --include "*.html" --cache-control "max-age=0,must-revalidate"
            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 is one year. CSS, JS, and image filenames do not change between builds on a plain static site, but the CloudFront invalidation after each deploy forces edge caches to fetch the latest versions. The immutable directive tells browsers to skip revalidation requests for these files entirely.

The second sync uploads only HTML files with max-age=0,must-revalidate. HTML pages are the entry points that reference all other assets. Browsers must check for updates on every request so they always get the latest version.

--delete on the first sync removes non-HTML files from S3 that no longer exist in the build output. The second sync does not use --delete because it only uploads HTML files and does not need to manage deletions separately.

post_build invalidates the CloudFront edge cache with /* so every edge location fetches the latest files from S3. Without this, edge caches would serve stale content until their TTL expires.

Deploy Stage

Add the Deploy stage to the pipeline’s Stages array. This stage uses CodeBuild as the provider, not the S3 deploy provider. CodeBuild gives you control over cache headers and the sync command.

        - Name: Deploy
          Actions:
            - Name: SiteDeploy
              ActionTypeId:
                Category: Build
                Owner: AWS
                Provider: CodeBuild
                Version: '1'
              InputArtifacts:
                - Name: BuildArtifact
              Configuration:
                ProjectName: !Ref DeployProject

The Category is Build, not Deploy, because the action type is CodeBuild. The pipeline’s stage name (Deploy) is just a label. What matters is the action type.

InputArtifacts references BuildArtifact from the Build stage. The deploy project receives the built files and syncs them to S3.

Update the pipeline’s service role from post 9 to include the deploy project. Add !GetAtt DeployProject.Arn to the CodeBuild permissions statement.

              - Effect: Allow
                Action:
                  - 'codebuild:BatchGetBuilds'
                  - 'codebuild:StartBuild'
                Resource:
                  - !GetAtt BuildProject.Arn
                  - !GetAtt DeployProject.Arn

Updating the Role

Your Git Sync role needs permissions for CloudFront response headers policies so CloudFormation can create and manage them. Add this statement to the inline policy in your role stack.

              - Effect: Allow
                Action:
                  - 'cloudfront:CreateResponseHeadersPolicy'
                  - 'cloudfront:GetResponseHeadersPolicy'
                  - 'cloudfront:UpdateResponseHeadersPolicy'
                  - 'cloudfront:DeleteResponseHeadersPolicy'
                Resource: '*'