Use a template to dynamically create and save a file to an S3 bucket

In the following AWS CloudFormation template, it creates a public Amazon S3 bucket to host a web site, files for each of the web pages, and a custom Lambda function that saves those files to that S3 bucket.

Create the following template in your working directly called template.yaml

Resources:
  # Create a bucket with random name
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      WebsiteConfiguration:
        ErrorDocument: not-found
        IndexDocument: index.html
      PublicAccessBlockConfiguration:
        BlockPublicAcls: TRUE
        BlockPublicPolicy: FALSE
        IgnorePublicAcls: TRUE
        RestrictPublicBuckets: FALSE
  # Make bucket open to the web
  S3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Statement:
          - Effect: Allow
            Principal: "*"
            Action:
              - "s3:GetObject"
            Resource:
              - !Sub "${S3Bucket.Arn}/*"
  # Add a file for the home page
  S3ContentHome:
    Type: Custom::Lambda
    Properties:
      ServiceToken: !GetAtt S3ContentCustomResource.Arn
      BucketName: !Ref S3Bucket
      Key: index.html
      ContentType: "text/html"
      Body: |
        <!DOCTYPE html>
        <html>
          <head>
            <title>Home!</title>
            <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
          </head>
          <body>
            <h1>Home!</h1>
            <a href="/">Home</a> | <a href="about-us">About Us</a>
          </body>
        </html>
  # Add a file for the about-us page
  S3ContentAboutUs:
    Type: Custom::Lambda
    Properties:
      ServiceToken: !GetAtt S3ContentCustomResource.Arn
      BucketName: !Ref S3Bucket
      Key: about-us
      ContentType: "text/html"
      Body: |
        <!DOCTYPE html>
        <html>
          <head>
            <title>About Us!</title>
            <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
          </head>
          <body>
            <h1>About Us!</h1>
            <a href="/">Home</a> | <a href="about-us">About Us</a>
          </body>
        </html>
  # Add a file for the 404 page
  S3ContentNotFound:
    Type: Custom::Lambda
    Properties:
      ServiceToken: !GetAtt S3ContentCustomResource.Arn
      BucketName: !Ref S3Bucket
      Key: not-found
      ContentType: "text/html"
      Body: |
        <!DOCTYPE html>
        <html>
          <head>
            <title>Oops, not found!</title>
            <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
          </head>
          <body>
            <h1>Oops, not found!</h1>
            <a href="/">&laquo; Go back home</a>
          </body>
        </html>
  # Add favicon
  S3ContentFavicon:
    Type: Custom::Lambda
    Properties:
      ServiceToken: !GetAtt S3ContentCustomResource.Arn
      BucketName: !Ref S3Bucket
      Key: favicon.ico
      ContentType: "image/x-icon"
      IsBase64Encoded: true
      Body: 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
  # Custom Lambda function that creates an object in specified S3 bucket and prefix
  S3ContentCustomResource:
    Type: AWS::Lambda::Function
    DependsOn: S3ContentCustomResourceLogGroup
    Properties:
      Code: 
        ZipFile: |
          const { S3Client, PutObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3");
          const s3Client = new S3Client();
          exports.handler = async function(event, context) {
            console.log("REQUEST RECEIVED:\n" + JSON.stringify(event));
            let responseStatus = "FAILED";
            let responseData = {};
            let physicalResourceId = event.ResourceProperties.Key;
            // For Delete requests, delete object.
            if (event.RequestType == "Delete") {
              console.log(`Deleting s3://${event.ResourceProperties.BucketName}/${event.ResourceProperties.Key}`);
              try {
                const deleteObjectCommand = new DeleteObjectCommand({
                  Bucket: event.ResourceProperties.BucketName,
                  Key: event.ResourceProperties.Key
                });
                await s3Client.send(deleteObjectCommand);
                responseStatus = "SUCCESS";
                console.log("Deleted");
              } catch (e) {
                console.error(`Failed to delete object: ${e.message}`);
              }
            } else {
              const body = typeof event.ResourceProperties.IsBase64Encoded == "string" && event.ResourceProperties.IsBase64Encoded.toLowerCase() == "true" ? Buffer.from(event.ResourceProperties.Body, 'base64') : event.ResourceProperties.Body;
              console.log(`Saving s3://${event.ResourceProperties.BucketName}/${event.ResourceProperties.Key}`);
              try {
                const putObjectCommand = new PutObjectCommand({
                  Body: body,
                  Bucket: event.ResourceProperties.BucketName,
                  Key: event.ResourceProperties.Key,
                  ContentType: event.ResourceProperties.ContentType
                });
                await s3Client.send(putObjectCommand);
                console.log("Saved");
                responseData["BucketName"] = event.ResourceProperties.BucketName;
                responseData["Key"] = event.ResourceProperties.Key;
                responseData["ContentType"] = event.ResourceProperties.ContentType;
                responseStatus = "SUCCESS";
              } catch (e) {
                console.log(`Could not save to S3: ${e.message}`);
              }
            }
            return await sendResponse(event, context, responseStatus, responseData, physicalResourceId);
          };
          // Send response to the pre-signed S3 URL 
          const sendResponse = async function(event, context, responseStatus, responseData, physicalResourceId) {
            let responseBody = JSON.stringify({
                Status: responseStatus,
                Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName,
                PhysicalResourceId: physicalResourceId,
                StackId: event.StackId,
                RequestId: event.RequestId,
                LogicalResourceId: event.LogicalResourceId,
                Data: responseData
            });
            console.log("RESPONSE BODY:\n", responseBody);
            await sendRequest(event.ResponseURL, {
              method: "PUT",
              body: responseBody
            })
          };
          // Web request
          const sendRequest = async function(url, opt) {
            opt = opt ? opt : {};
            const parsedUrl = require("url").parse(url);
            let headers = opt.headers ? opt.headers : {};
            headers["Content-length"] = opt.body ? opt.body.length : 0;
            const options = {
              hostname: parsedUrl.hostname,
              port: opt.port ? opt.port : (parsedUrl.protocol == "https:" ? 443 : 80),
              path: parsedUrl.path,
              method: opt.method ? opt.method : "GET",
              headers: headers
            };
            let response = await new Promise(function(res, err) {
              let request = require(parsedUrl.protocol == "https:" ? "https" : "http").request(options, function(response) {
                let responseText = [];
                response.on("data", function(d) {
                  responseText.push(d);
                });
                response.on("end", function() {
                  response.responseText = responseText.join("");
                  res(response);
                });
              });
              request.on("error", function(error) {
                console.error("sendRequest Error: " + error);
                err(error);
              });
              request.write(opt.body ? opt.body : "");
              request.end();
            });
            return response;
          };
      FunctionName: !Sub
        - "S3ContentCustomResource-${id}"
        - id: !Select 
            - 0
            - !Split 
              - "-"
              - !Select
                - 2
                - !Split
                  - "/"
                  - !Ref AWS::StackId
      Handler: index.handler
      Role: !GetAtt S3ContentCustomResourceRole.Arn
      Runtime: nodejs20.x
  # Role for custom Lambda function to log activity and put/delete objects in S3 bucket created in this template
  S3ContentCustomResourceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Policies:
        - PolicyName: LambdaExecute
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource:
                  - !Sub
                      - "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/S3ContentCustomResource-${id}*"
                      - id: !Select 
                        - 0
                        - !Split 
                          - "-"
                          - !Select
                            - 2
                            - !Split
                              - "/"
                              - !Ref AWS::StackId
              - Effect: Allow
                Action:
                  - "s3:PutObject"
                  - "s3:DeleteObject"
                Resource: !Sub "${S3Bucket.Arn}/*"
  # Log group for Lambda function
  S3ContentCustomResourceLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub
        - "/aws/lambda/S3ContentCustomResource-${id}"
        - id: !Select 
            - 0
            - !Split 
              - "-"
              - !Select
                - 2
                - !Split
                  - "/"
                  - !Ref AWS::StackId
      RetentionInDays: 7

Outputs:
  # URL of S3 bucket open to the web
  HomeUrl:
    Value: !Sub "http://${S3Bucket}.s3-website-${AWS::Region}.amazonaws.com"

To deploy

aws cloudformation create-stack \
  --region us-east-1 \
  --stack-name test-s3-content \
  --template-body file://template.yaml \
  --capabilities CAPABILITY_IAM;

To get URL to visit

aws cloudformation describe-stacks \
  --region us-east-1 \
  --stack-name test-s3-content \
  --query 'Stacks[0].Outputs[0].OutputValue' \
  --output text;

To delete

aws cloudformation delete-stack \
  --region us-east-1 \
  --stack-name test-s3-content;