Enterprise Static Asset Delivery CDN

Build a CloudFront CDN with WAF protection and CI/CD pipeline using Floci.

Introduction

30 Second Summary

Every time you load a webpage and images, styles, and scripts appear instantly, there's a content delivery network working behind the scenes. These networks cache files at edge locations around the world so users never wait for content to travel across continents.

In this project, you will build a production-grade CDN infrastructure using AWS CloudFront, S3, and a Web Application Firewall. You'll run everything locally with Floci so you use real AWS CLI commands without any cloud costs.

What You'll Build

A complete static asset delivery platform with a private S3 bucket, a CloudFront distribution secured by Origin Access Control, WAF protection with rate limiting, and a CI/CD pipeline for automated deployments.

By the end of this project, you'll have:

  • A private S3 bucket with organized asset folders and a bucket policy restricting access to CloudFront only.
  • A CloudFront distribution secured with Origin Access Control, HTTPS redirection, and compression enabled for fast delivery.
  • A WAF Web ACL with rate-limiting and HTTP method restriction rules, plus a GitHub Actions workflow that automatically syncs assets and invalidates the cache on push.
  • Secret Mission: Configure a Route 53 hosted zone with alias records to simulate custom-domain DNS resolution for your CDN.

Are there any prerequisites?

This project assumes you already have Docker, the AWS CLI, Git, and VS Code installed on your machine. You also need a GitHub account.

Step 1 walks you through installing Floci and connecting the AWS CLI to it, so no prior Floci experience is needed.

Set Up Floci and Configure the AWS CLI

The CDN infrastructure you're building uses multiple AWS services: S3 for storage, CloudFront for distribution, and WAF for protection. Instead of provisioning real cloud resources, you'll use Floci to emulate the entire AWS environment on your local machine.

Floci runs the same API endpoints as real AWS services. Your AWS CLI commands work identically whether they target the local emulator or the cloud. In this step, you'll install Floci, point the AWS CLI at it, and create the project folder structure.

In this step, get ready to:
  • Install and start the Floci AWS emulator.
  • Configure the AWS CLI to connect to the local emulator.
  • Create the project directory structure.
Install and start Floci

Floci is an AWS emulator that runs inside Docker on your machine. It exposes the same endpoints as real AWS, so every CLI command you run locally is identical to what you'd run in production.

What is Floci?

Floci is a free, open-source tool that emulates 68+ AWS services locally. It listens on port 4566 and accepts the same API calls as the real AWS cloud.

This means you can practice building production infrastructure without any cloud costs or account setup.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Install the Floci CLI by running this command:

macOS

curl -fsSL https://floci.io/install.sh | sh

Windows

irm https://floci.io/install.ps1 | iex

What does this command do?

The command downloads the Floci install script from the official website and executes it. On macOS, curl fetches the script and pipes it to sh. On Windows, irm (Invoke-RestMethod) downloads the script and pipes it to iex (Invoke-Expression).

Seeing a permission error?

On macOS, you may need to prefix the command with sudo. On Windows, make sure you opened PowerShell as Administrator by right-clicking and selecting Run as administrator.

Also confirm that Docker is running before proceeding. Floci requires Docker to operate. Help me fix this Floci installation error.

  • Start the Floci AWS emulator by running:
floci start
  • Confirm Floci is running by running:
floci status

You should see output confirming the emulator is active and listening on port 4566.

Floci won't start?

Make sure Docker Desktop is running. Floci uses Docker under the hood to spin up the emulator container.

If you see a port conflict, another process may be using port 4566. Stop any other local emulators (like LocalStack) before retrying. Help me get Floci running.

Configure the AWS CLI environment variables

The AWS CLI needs to know where to send requests. By setting four environment variables, you redirect all aws commands from the real cloud to Floci's local endpoint.

  • Set the environment variables by running these commands:

macOS

export AWS_ENDPOINT_URL="http://localhost:4566"
export AWS_DEFAULT_REGION="us-east-1"
export AWS_ACCESS_KEY_ID="test"
export AWS_SECRET_ACCESS_KEY="test"

Windows

$env:AWS_ENDPOINT_URL = "http://localhost:4566"
$env:AWS_DEFAULT_REGION = "us-east-1"
$env:AWS_ACCESS_KEY_ID = "test"
$env:AWS_SECRET_ACCESS_KEY = "test"

What do these variables do?

  • AWS_ENDPOINT_URL tells the CLI to send all API calls to http://localhost:4566 (Floci) instead of the real AWS endpoints.
  • AWS_DEFAULT_REGION sets the region to us-east-1. Floci responds to any region, but the WAF rules you'll create later require this specific region.
  • AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are dummy credentials. Floci accepts any non-empty values here since no real authentication is needed.
  • Verify the CLI can reach Floci by running:
aws s3 ls

You should see an empty result with no errors. There are no buckets yet, so no output is expected. The important thing is that the command completes without an error message.

Seeing a connection error?

If you see a message about connection refused or unable to connect, confirm Floci is still running with floci status. If it stopped, run floci start again.

If the error mentions invalid credentials or access denied, double-check that you set all four environment variables in the same terminal session. Opening a new terminal window resets them. Help me fix this AWS CLI connection to Floci.

These variables are session-only

Environment variables set this way only last for the current terminal session. If you close the terminal and reopen it later, you'll need to set them again before running any aws commands.

Create the project directory structure

Your project needs a clean folder structure. The assets/ subfolders will hold the static files your CDN delivers. The infra/ folder will hold JSON configuration files for each AWS service.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir cdn-platform
cd cdn-platform
  • Create the subdirectory structure by running:

macOS

mkdir -p assets/images assets/css assets/js assets/docs infra

Windows

mkdir assets/images, assets/css, assets/js, assets/docs, infra

What is this structure for?

  • assets/images/ stores image files (logos, icons, photos).
  • assets/css/ stores stylesheets.
  • assets/js/ stores JavaScript files.
  • assets/docs/ stores downloadable documents like PDFs.
  • infra/ stores the JSON configs you'll pass to AWS CLI commands when creating CloudFront, WAF, and other resources.
  • Confirm the folders exist by running:
ls

You should see assets and infra listed in the output.

  • Verify the nested folders inside assets/ by running:
ls assets

You should see css, docs, images, and js listed.

Missing folders?

Make sure you ran the mkdir command from inside the cdn-platform directory. If ls shows nothing, check your current directory with pwd (macOS) or Get-Location (Windows). You should be inside ~/Desktop/cdn-platform. Help me fix my directory structure.

Your local AWS environment is ready and your project structure is in place. Next up, you'll create the S3 asset bucket and upload content to it.

Create the S3 Asset Bucket and Upload Content

Your local AWS environment is ready. Now it is time to fill it with the actual content your CDN will serve.

S3 is the storage layer in this architecture. You will create a private bucket, upload static assets with the correct content-type metadata, and then lock the bucket down so only CloudFront can read from it.

In this step, get ready to:
  • Create a private S3 bucket and upload four asset types with correct metadata.
  • Write a bucket policy restricting access to CloudFront only.
  • Verify the bucket contents and policy are correctly configured.
Create the S3 bucket and asset files

Every CDN needs an origin to pull content from. Your origin is an S3 bucket named enterprise-assets.

  • Create the S3 bucket by running this command in your terminal:
aws s3 mb s3://enterprise-assets
  • You should see make_bucket: enterprise-assets in the output.

Seeing a connection error?

Make sure Floci is still running. Run floci status to check. If it is not running, start it again with floci start.

Also confirm your AWS_ENDPOINT_URL environment variable is still set to http://localhost:4566. Environment variables reset when you close the terminal.

Help me fix my Floci connection

Now you need to create the asset files that will live in your bucket. Open the cdn-platform folder in VS Code if it is not already open.

  • Create a new file at assets/css/main.css and paste in the following CSS:
:root {
  --primary-color: #1a73e8;
  --font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

body {
  font-family: var(--font-stack);
  margin: 0;
  padding: 0;
  background-color: #f5f5f5;
}

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

What does this CSS do?

This is a sample enterprise stylesheet. It defines CSS custom properties (variables) for brand colors and font stacks, then applies basic page layout and a centered container.

In production, this file would be much larger. The important thing here is that it gives you a real CSS file to test content-type metadata and cache behavior with CloudFront.

  • Save main.css.
  • Create a new file at assets/js/app.js and paste in this JavaScript:
(function () {
  "use strict";

  function initApp() {
    console.log("Enterprise CDN asset loaded successfully");
    document.addEventListener("DOMContentLoaded", function () {
      var timestamp = new Date().toISOString();
      console.log("Assets served via CloudFront CDN at " + timestamp);
    });
  }

  initApp();
})();

What does this JavaScript do?

This is a self-executing function (IIFE) that logs a timestamp when the page loads. It simulates a real application script that would be served through your CDN.

The "use strict" directive enables strict mode, which catches common JavaScript mistakes. Wrapping everything in an IIFE prevents polluting the global scope.

  • Save app.js.

The remaining two files are placeholders simulating binary assets (an image and a PDF). In production, these would be actual binary files. For learning purposes, text files let you inspect the content-type metadata behavior.

  • Create a new file at assets/images/logo.txt and paste in this content:
This is a placeholder file simulating a PNG image.
In production, this would be an actual image binary.
Served via CloudFront with image/png content-type.
  • Save logo.txt.
  • Create a new file at assets/docs/sample.txt and paste in this content:
This is a placeholder file simulating a PDF document.
In production, this would be an actual PDF binary.
Served via CloudFront with application/pdf content-type.
  • Save sample.txt.

Now upload all four files to the bucket with explicit content-type metadata. This metadata tells browsers and caches how to handle each file.

  • Upload the CSS file by running:
aws s3 cp assets/css/main.css s3://enterprise-assets/assets/css/main.css --content-type "text/css"
  • Upload the JavaScript file by running:
aws s3 cp assets/js/app.js s3://enterprise-assets/assets/js/app.js --content-type "application/javascript"
  • Upload the PDF placeholder by running:
aws s3 cp assets/docs/sample.txt s3://enterprise-assets/assets/docs/sample.txt --content-type "application/pdf"
  • Upload the image placeholder by running:
aws s3 cp assets/images/logo.txt s3://enterprise-assets/assets/images/logo.txt --content-type "image/png"

Why set content-type explicitly?

S3 guesses content-type based on file extension. Since your placeholder files use .txt extensions, S3 would default to text/plain.

The --content-type flag overrides this so CloudFront forwards the correct MIME type to browsers. Without it, a browser might try to render a PDF as plain text.

  • Each upload command should print an upload: line confirming the file was copied to S3.

Upload failing with a file not found error?

Make sure your terminal is in the cdn-platform directory. The paths in the commands are relative to this folder.

Run ls assets/css/main.css (or dir assets\css\main.css on Windows) to confirm the file exists locally before retrying the upload.

Help me troubleshoot my S3 upload

Write and apply the bucket policy

Right now, anyone with the right AWS credentials could read from this bucket. A bucket policy locks it down so only your CloudFront distribution can access the objects.

  • Create a new file at infra/bucket-policy.json and paste in this policy:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::enterprise-assets/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::000000000000:distribution/DIST_ID_PLACEHOLDER"
        }
      }
    }
  ]
}

What does this policy do?

The Principal field allows only the cloudfront.amazonaws.com service to perform s3:GetObject. No other identity can read objects from this bucket.

The Condition block adds a second layer of security. It checks that the request came from a specific CloudFront distribution ARN. Even other CloudFront distributions cannot access this bucket.

The placeholder DIST_ID_PLACEHOLDER will be replaced with your real distribution ID in the next step after you create the CloudFront distribution.

  • Save bucket-policy.json.

What is Origin Access Control?

Origin Access Control (OAC) is the mechanism that lets CloudFront prove its identity to S3. When CloudFront fetches an object, it signs the request using SigV4. S3 then checks the signature against this bucket policy.

This is the recommended approach since 2022, replacing the older Origin Access Identity (OAI) method. OAC supports server-side encryption with KMS and works in all AWS regions.

  • Apply the bucket policy by running this command:
aws s3api put-bucket-policy --bucket enterprise-assets --policy file://infra/bucket-policy.json
  • The command returns silently on success. No output means the policy was accepted.

Seeing a MalformedPolicy error?

Double-check that your JSON is valid. A missing comma or mismatched brace will cause this error. Open infra/bucket-policy.json in VS Code and look for red underlines indicating syntax issues.

Also make sure you are running the command from the cdn-platform directory so the file://infra/bucket-policy.json path resolves correctly.

Help me fix my bucket policy error

Verify the bucket configuration

Before moving on to CloudFront, confirm that all four assets are in the bucket and the policy is active.

  • List all objects in the bucket by running:
aws s3 ls s3://enterprise-assets --recursive
  • You should see four files listed: assets/css/main.css, assets/js/app.js, assets/docs/sample.txt, and assets/images/logo.txt.
  • Confirm the bucket policy is applied by running:
aws s3api get-bucket-policy --bucket enterprise-assets
  • You should see the JSON policy you just applied, including the AllowCloudFrontServicePrincipal statement and the cloudfront.amazonaws.com principal.

Bucket listing shows fewer than 4 files?

Check which files are missing. Re-run the corresponding aws s3 cp command for any missing file. A typo in the S3 key path (the part after s3://enterprise-assets/) is the most common cause.

Help me find my missing S3 uploads

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved in VS Code before continuing.

ⓧ I'd like to double check the full code

Here is each file as it should look in your project.

:root {
  --primary-color: #1a73e8;
  --font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

body {
  font-family: var(--font-stack);
  margin: 0;
  padding: 0;
  background-color: #f5f5f5;
}

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}
(function () {
  "use strict";

  function initApp() {
    console.log("Enterprise CDN asset loaded successfully");
    document.addEventListener("DOMContentLoaded", function () {
      var timestamp = new Date().toISOString();
      console.log("Assets served via CloudFront CDN at " + timestamp);
    });
  }

  initApp();
})();
This is a placeholder file simulating a PNG image.
In production, this would be an actual image binary.
Served via CloudFront with image/png content-type.
This is a placeholder file simulating a PDF document.
In production, this would be an actual PDF binary.
Served via CloudFront with application/pdf content-type.
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::enterprise-assets/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::000000000000:distribution/DIST_ID_PLACEHOLDER"
        }
      }
    }
  ]
}

Your S3 origin is loaded and locked down. Next, you will deploy a CloudFront distribution with Origin Access Control that can actually read from this bucket.

Deploy the CloudFront Distribution with OAC

Your S3 bucket is locked down. The bucket policy only allows requests from CloudFront, so nobody can access your assets directly.

Now you need the CloudFront distribution itself. In this step, you will create an Origin Access Control (OAC) that signs every request CloudFront makes to S3, then deploy a full distribution that uses it.

In this step, get ready to:
  • Create an OAC configuration and register it with CloudFront.
  • Deploy a CloudFront distribution that points to your S3 bucket using the OAC.
  • Verify the distribution is enabled and properly configured.
Create an Origin Access Control (OAC)

An OAC tells CloudFront how to authenticate when it fetches objects from your private S3 bucket. It uses sigv4 signing, the same protocol AWS uses internally for service-to-service requests.

  • In VS Code, create a new file called oac-config.json inside your infra/ folder.
  • Paste in the following JSON:
{
  "Name": "enterprise-assets-oac",
  "Description": "OAC for enterprise static assets S3 bucket",
  "SigningProtocol": "sigv4",
  "SigningBehavior": "always",
  "OriginAccessControlOriginType": "s3"
}

What does this configuration do?

Each field tells CloudFront how to handle authentication with your S3 origin:

  • SigningProtocol set to sigv4 means CloudFront signs requests using AWS Signature Version 4.
  • SigningBehavior set to always ensures every single origin request is signed, with no exceptions.
  • OriginAccessControlOriginType set to s3 tells CloudFront this OAC is meant for an S3 origin.
  • Save infra/oac-config.json.
  • Register the OAC with CloudFront by running this command in your terminal:
aws cloudfront create-origin-access-control --origin-access-control-config file://infra/oac-config.json

What should I see?

The command returns a JSON response containing an OriginAccessControl object. Look for the Id field inside it.

Copy this Id value and save it somewhere. You will need it in the next substep when you configure the distribution.

Seeing an error about the config file?

  • Make sure you are in the cdn-platform directory so the relative path infra/oac-config.json resolves correctly.
  • Verify the JSON is valid. A missing comma or extra bracket will cause a parse error.
  • Confirm Floci is still running with floci status.

Help me troubleshoot my OAC creation error.

Write the distribution config and create the distribution

The distribution config is a JSON file that defines your CDN's behavior. It tells CloudFront where to fetch content from, how long to cache it, and which HTTP methods to allow.

  • In VS Code, create a new file called distribution-config.json inside your infra/ folder.
  • Paste in the following JSON:
{
  "CallerReference": "enterprise-cdn-dist-001",
  "Origins": {
    "Quantity": 1,
    "Items": [
      {
        "Id": "S3-enterprise-assets",
        "DomainName": "enterprise-assets.s3.amazonaws.com",
        "OriginPath": "",
        "CustomHeaders": {
          "Quantity": 0
        },
        "S3OriginConfig": {
          "OriginAccessIdentity": ""
        },
        "OriginAccessControlId": "[[OAC_ID="your OAC ID from the previous command"]]"
      }
    ]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-enterprise-assets",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"],
      "CachedMethods": {
        "Quantity": 2,
        "Items": ["GET", "HEAD"]
      }
    },
    "Compress": true,
    "DefaultTTL": 86400,
    "MinTTL": 0,
    "MaxTTL": 31536000,
    "ForwardedValues": {
      "QueryString": false,
      "Cookies": {
        "Forward": "none"
      }
    }
  },
  "Comment": "Enterprise static asset CDN distribution",
  "Enabled": true
}

What does this configuration do?

The distribution config has two main sections:

  • Origins defines where CloudFront fetches content. The DomainName points to your S3 bucket. OriginAccessIdentity is empty because you are using the newer OAC approach instead of the legacy OAI. The OriginAccessControlId links to the OAC you just created.
  • DefaultCacheBehavior controls how CloudFront handles requests. redirect-to-https forces secure connections. Only GET and HEAD methods are allowed. Compress enables gzip/brotli for smaller payloads. DefaultTTL of 86400 means assets are cached for 24 hours by default.

Why is OriginAccessIdentity empty?

OAI (Origin Access Identity) is the legacy method for restricting S3 access. OAC (Origin Access Control) replaced it in 2022 with better security features like SSE-KMS support.

When using OAC, you must set OriginAccessIdentity to an empty string to tell CloudFront you are not using the old method.

  • Save infra/distribution-config.json.
  • Create the CloudFront distribution by running:
aws cloudfront create-distribution --distribution-config file://infra/distribution-config.json

What should I see?

A large JSON response containing a Distribution object. Find these two values in the output and copy them somewhere safe:

  • Distribution.Id is your distribution ID. You will need this in the WAF and CI/CD steps.
  • Distribution.DomainName is your CDN endpoint. This is where users would access your assets.

Getting an error creating the distribution?

  • If you see a JSON parsing error, check that you replaced the OriginAccessControlId placeholder with your actual OAC ID. The value should be a plain string with no extra quotes wrapping it.
  • If you see a missing required field error, verify your JSON structure matches the example exactly. Every comma and bracket matters.
  • Confirm Floci is running with floci status.

Help me fix my CloudFront distribution creation error.

Verify the distribution is configured correctly

Before moving on, confirm that your distribution is deployed with the OAC attached and the correct origin.

  • Verify the distribution by running this command (replace the distribution ID with yours):
aws cloudfront get-distribution --id [[DIST_ID="your distribution ID from the previous command"]]

What should I check in the output?

Look for these three confirmations in the JSON response:

  • Enabled is set to true. This means your distribution is active.
  • OriginAccessControlId matches the OAC ID you created in the first substep.
  • DomainName inside the Origins section shows enterprise-assets.s3.amazonaws.com. This confirms the distribution pulls from your bucket.
  • Confirm all three checks pass before continuing.

OAC ID missing from the output?

  • If OriginAccessControlId is empty or missing, the distribution was likely created with a blank value. Re-check your infra/distribution-config.json file to ensure the OAC ID is filled in correctly.
  • You may need to delete the distribution and recreate it with the corrected config.

Help me verify my CloudFront distribution configuration.

✔️ Awesome, I've got everything!

Great. Make sure you have your distribution ID and domain name saved. You will need both in the next steps.

ⓧ I'd like to double check the full code

Here is what infra/oac-config.json should contain:

{
  "Name": "enterprise-assets-oac",
  "Description": "OAC for enterprise static assets S3 bucket",
  "SigningProtocol": "sigv4",
  "SigningBehavior": "always",
  "OriginAccessControlOriginType": "s3"
}

Here is what infra/distribution-config.json should contain (with your actual OAC ID in place of the placeholder):

{
  "CallerReference": "enterprise-cdn-dist-001",
  "Origins": {
    "Quantity": 1,
    "Items": [
      {
        "Id": "S3-enterprise-assets",
        "DomainName": "enterprise-assets.s3.amazonaws.com",
        "OriginPath": "",
        "CustomHeaders": {
          "Quantity": 0
        },
        "S3OriginConfig": {
          "OriginAccessIdentity": ""
        },
        "OriginAccessControlId": "OAC_ID_PLACEHOLDER"
      }
    ]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "S3-enterprise-assets",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"],
      "CachedMethods": {
        "Quantity": 2,
        "Items": ["GET", "HEAD"]
      }
    },
    "Compress": true,
    "DefaultTTL": 86400,
    "MinTTL": 0,
    "MaxTTL": 31536000,
    "ForwardedValues": {
      "QueryString": false,
      "Cookies": {
        "Forward": "none"
      }
    }
  },
  "Comment": "Enterprise static asset CDN distribution",
  "Enabled": true
}

Your CDN is live. CloudFront is now the only path to your S3 assets, authenticated via OAC. Next up, you will add a WAF Web ACL to protect the distribution from abuse with rate limiting and method restrictions.

Configure WAF Protection

Your CloudFront distribution is live, but it has no defense against abuse. A single bad actor could hammer your CDN with thousands of requests per second, racking up origin hits and degrading performance for everyone else.

A Web Application Firewall (WAF) sits in front of your distribution and evaluates every incoming request against a set of rules before it reaches CloudFront. In this step, you will create a WAF Web ACL with two protective rules: one that blocks non-read HTTP methods and one that rate-limits individual IPs.

In this step, get ready to:
  • Write the WAF rules JSON defining rate-limiting and method restrictions.
  • Create the Web ACL and associate it with your CloudFront distribution.
  • Verify the WAF configuration is active and protecting your CDN.
Write the WAF rules configuration

WAF rules are defined as JSON and passed to the Web ACL at creation time. You will define two rules: one blocks any HTTP method that is not GET (so attackers cannot POST, PUT, or DELETE against your CDN), and the other blocks any IP that exceeds 1000 requests in a 5-minute window.

  • Create the rules file by running this command:
cat > infra/waf-rules.json << 'EOF'
[
  {
    "Name": "BlockNonReadMethods",
    "Priority": 0,
    "Statement": {
      "NotStatement": {
        "Statement": {
          "ByteMatchStatement": {
            "SearchString": "GET",
            "FieldToMatch": {
              "Method": {}
            },
            "TextTransformations": [
              {
                "Priority": 0,
                "Type": "NONE"
              }
            ],
            "PositionalConstraint": "EXACTLY"
          }
        }
      }
    },
    "Action": {
      "Block": {}
    },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "BlockNonReadMethods"
    }
  },
  {
    "Name": "RateLimitPerIP",
    "Priority": 1,
    "Statement": {
      "RateBasedStatement": {
        "Limit": 1000,
        "AggregateKeyType": "IP"
      }
    },
    "Action": {
      "Block": {}
    },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "RateLimitPerIP"
    }
  }
]
EOF

What do these rules do?

The BlockNonReadMethods rule uses a NotStatement wrapping a ByteMatchStatement. It matches when the HTTP method is NOT exactly "GET" and blocks the request. This ensures your CDN only serves content and never accepts write operations.

The RateLimitPerIP rule uses a RateBasedStatement with a limit of 1000 requests per 5-minute window aggregated by IP address. Any single IP exceeding that threshold gets blocked automatically until their rate drops.

  • Confirm the file was created by running:
cat infra/waf-rules.json

You should see the full JSON array with both rules printed to your terminal.

File looks empty or has errors?

Make sure you copied the entire cat > infra/waf-rules.json << 'EOF' block including the closing EOF line. The heredoc syntax requires that final EOF on its own line with no leading spaces.

Help me fix my waf-rules.json file

Create the Web ACL and associate it with CloudFront

The Web ACL is the container that holds your rules and attaches to a resource. You will create it with scope CLOUDFRONT and region us-east-1 (required for CloudFront-scoped WAF resources), then associate it with your distribution.

  • Create the Web ACL by running this command:
aws wafv2 create-web-acl --name cdn-protection --scope CLOUDFRONT --default-action Allow={} --rules file://infra/waf-rules.json --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=cdn-protection --region us-east-1

What does this command do?

  • --name cdn-protection gives the Web ACL a human-readable name.
  • --scope CLOUDFRONT tells WAF this ACL protects a CloudFront distribution (not a regional resource like an ALB).
  • --default-action Allow={} means requests that do not match any rule are allowed through. Only rule-matched traffic gets blocked.
  • --rules file://infra/waf-rules.json loads the two rules you just wrote.
  • --visibility-config enables request sampling and CloudWatch metrics so you can monitor WAF activity.
  • The command returns a JSON response containing the Web ACL's ARN and ID. Save the ARN for the next command:
$WEB_ACL_ARN = "arn:aws:wafv2:us-east-1:000000000000:global/webacl/cdn-protection/YOUR_ACL_ID"

Finding the ARN in the output

Look for the ARN field in the Summary section of the create-web-acl response. Copy the full ARN string and paste it into the variable assignment above, replacing the placeholder.

  • Associate the Web ACL with your CloudFront distribution by running:
aws wafv2 associate-web-acl --web-acl-arn $WEB_ACL_ARN --resource-arn arn:aws:cloudfront::000000000000:distribution/$DIST_ID

What is associate-web-acl doing?

This command links your WAF Web ACL to the CloudFront distribution. Once associated, every request hitting your distribution passes through the WAF rules before CloudFront processes it. The resource ARN follows the pattern arn:aws:cloudfront::000000000000:distribution/$DIST_ID where 000000000000 is the Floci default account ID.

Seeing WAFNonexistentItemException?

This usually means the Web ACL ARN or distribution ARN is incorrect. Double-check that $WEB_ACL_ARN contains the full ARN from the create-web-acl output. Also confirm $DIST_ID still holds the distribution ID from the previous step.

Help me fix my WAF association error

Verify the WAF configuration
  • Retrieve the Web ACL details to confirm both rules are active by running:
aws wafv2 get-web-acl --name cdn-protection --scope CLOUDFRONT --id $WEB_ACL_ID --region us-east-1

What should I see?

The response contains a WebACL object with a Rules array. You should see two entries: BlockNonReadMethods at priority 0 with a NotStatement wrapping a ByteMatchStatement, and RateLimitPerIP at priority 1 with a RateBasedStatement. The DefaultAction should show Allow.

Only seeing one rule or no rules?

If the Rules array is empty or incomplete, your infra/waf-rules.json may have a JSON syntax error. Run cat infra/waf-rules.json and check for missing commas or brackets. Recreate the Web ACL after fixing the file.

Help me debug my WAF rules

✔️ Awesome, I've got everything!

Great. Double check you have saved infra/waf-rules.json and confirmed both rules appear in the get-web-acl output.

ⓧ I'd like to double check the full code

Here is the complete infra/waf-rules.json file:

[
  {
    "Name": "BlockNonReadMethods",
    "Priority": 0,
    "Statement": {
      "NotStatement": {
        "Statement": {
          "ByteMatchStatement": {
            "SearchString": "GET",
            "FieldToMatch": {
              "Method": {}
            },
            "TextTransformations": [
              {
                "Priority": 0,
                "Type": "NONE"
              }
            ],
            "PositionalConstraint": "EXACTLY"
          }
        }
      }
    },
    "Action": {
      "Block": {}
    },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "BlockNonReadMethods"
    }
  },
  {
    "Name": "RateLimitPerIP",
    "Priority": 1,
    "Statement": {
      "RateBasedStatement": {
        "Limit": 1000,
        "AggregateKeyType": "IP"
      }
    },
    "Action": {
      "Block": {}
    },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "RateLimitPerIP"
    }
  }
]

Your CDN now has a security layer that blocks abusive traffic before it ever reaches CloudFront. Next up, you will build a GitHub Actions workflow that automates deploying new assets and invalidating the cache on every push.

Build the GitHub Actions CI/CD Workflow

Your CDN infrastructure is fully protected. The S3 bucket is locked down, CloudFront serves assets with OAC, and the WAF blocks abusive traffic. But right now, deploying new assets requires running manual commands every time.

In this step, you will build a GitHub Actions CI/CD workflow that automatically syncs assets to S3 and invalidates the CloudFront cache whenever you push changes. Then you will test the full pipeline locally against Floci.

In this step, get ready to:
  • Create a GitHub Actions workflow YAML with trigger and credential configuration.
  • Add S3 sync and CloudFront cache invalidation steps to the workflow.
  • Test the deploy-and-invalidate pipeline locally against Floci.
Create the workflow file with trigger and credentials

GitHub Actions workflows live in a special directory structure that GitHub recognizes automatically. You need to create this directory inside your project before writing the YAML file.

  • Create the workflow directory by running these commands:
mkdir -p .github/workflows
  • Confirm the directory exists by running:
ls .github/workflows
  • You should see the empty directory listed with no errors.
  • Create deploy-assets.yml inside .github/workflows/ and paste the following workflow configuration:
name: Deploy Static Assets

on:
  push:
    branches:
      - main
    paths:
      - "assets/**"

env:
  AWS_REGION: us-east-1
  BUCKET_NAME: enterprise-assets

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

What does this workflow do so far?

  • on: push: triggers the workflow only when you push to the main branch AND the push includes changes inside the assets/ folder. Pushes to other branches or other folders are ignored.
  • env: defines reusable environment variables so you only set the region and bucket name once.
  • actions/checkout@v6 clones your repository into the runner so subsequent steps can access your files.
  • aws-actions/configure-aws-credentials@v6 reads your AWS secrets from GitHub's encrypted secrets store and configures the CLI on the runner.
  • Save the file. You will add the deployment steps next.

YAML indentation errors?

YAML is strict about spaces. Make sure you use exactly 2 spaces per indent level (not tabs). The steps: key is indented 4 spaces from the left, and each - name: entry is indented 6 spaces.

Help me fix YAML indentation in my GitHub Actions workflow

Add the S3 sync and CloudFront invalidation steps

The credential step gives the runner access to AWS. Now you need the actual deployment logic: syncing files to S3 and telling CloudFront to clear its cache so users get the latest version of your assets.

  • In deploy-assets.yml, add the following steps directly after the Configure AWS credentials step:
      - name: Sync assets to S3
        run: |
          aws s3 sync ./assets s3://${{ env.BUCKET_NAME }} \
            --delete \
            --cache-control "public, max-age=31536000"

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.DISTRIBUTION_ID }} \
            --paths "/*"

What do these deployment steps do?

  • aws s3 sync compares your local assets/ folder with the S3 bucket and only uploads changed files. The --delete flag removes files from S3 that no longer exist locally.
  • --cache-control "public, max-age=31536000" sets a one-year cache header on every uploaded file. CloudFront uses this to know how long to cache assets at edge locations.
  • aws cloudfront create-invalidation tells CloudFront to clear its cached copies immediately. The path "/*" invalidates all files in the distribution.
  • ${{ secrets.DISTRIBUTION_ID }} is a GitHub Actions secret. In production, you would store your real distribution ID there so it is never exposed in code.
  • Save deploy-assets.yml.

Why automate with GitHub Actions instead of running commands manually?

Manual deployments are error-prone. You might forget the --delete flag, skip the invalidation, or deploy from the wrong branch. A workflow guarantees the same steps run every time, in the right order, triggered by a simple git push.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

name: Deploy Static Assets

on:
  push:
    branches:
      - main
    paths:
      - "assets/**"

env:
  AWS_REGION: us-east-1
  BUCKET_NAME: enterprise-assets

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Sync assets to S3
        run: |
          aws s3 sync ./assets s3://${{ env.BUCKET_NAME }} \
            --delete \
            --cache-control "public, max-age=31536000"

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.DISTRIBUTION_ID }} \
            --paths "/*"
Test the pipeline locally against Floci

The workflow YAML is ready for GitHub. Before pushing to a real repository, you can test the equivalent commands locally against Floci to confirm they work.

  • Sync your local assets to the S3 bucket by running this command:
aws s3 sync ./assets s3://enterprise-assets --delete
  • You should see output listing the files that were uploaded or confirmed as synced.

Seeing a NoSuchBucket error?

Make sure your Floci emulator is still running. Run floci status to check. If it is stopped, restart it with floci start and confirm your AWS_ENDPOINT_URL environment variable is still set to http://localhost:4566.

Help me troubleshoot the NoSuchBucket error when running aws s3 sync against Floci

  • Create a CloudFront cache invalidation by running this command:
aws cloudfront create-invalidation --distribution-id [[DIST_ID="your distribution ID from Step 3"]] --paths "/*"
  • You should see a JSON response containing an Invalidation object with an Id field and Status set to InProgress or Completed.
  • Verify the invalidation was recorded by running:
aws cloudfront list-invalidations --distribution-id [[DIST_ID="your distribution ID from Step 3"]]
  • You should see an InvalidationList containing at least one entry with the invalidation ID from the previous command.

What did this local test prove?

By running the same aws s3 sync and aws cloudfront create-invalidation commands locally, you confirmed the CLI logic works before committing the workflow. If these commands succeed against Floci, they will succeed against real AWS when your GitHub Actions runner executes them.

Invalidation returning an error?

Double check that you replaced the distribution ID placeholder with the actual ID you captured in Step 3. You can find it again by running aws cloudfront list-distributions and looking for the Id field in the output.

Help me fix the CloudFront invalidation error when testing against Floci

Secret mission

Add Route 53 DNS Resolution

Your CDN delivers assets through CloudFront, but users still need to know the distribution's domain name. In production, you'd map a friendly custom domain to the CDN. In this secret mission, you'll configure Route 53 DNS to resolve a simulated custom domain directly to your CloudFront distribution, completing the full request path from domain name to cached asset.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally via Floci, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Floci emulator container (running on port 4566, hosting all emulated AWS services: S3, CloudFront, WAF, Route 53).
  • Local project directory cdn-platform/ on your Desktop (contains assets/, infra/, and .github/ folders).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with the CDN infrastructure.

  • Your Floci container stays running on port 4566. All emulated AWS resources (S3 bucket, CloudFront distribution, WAF Web ACL, Route 53 hosted zone) remain in memory.
  • Your local cdn-platform/ folder stays on your Desktop with all config files and assets intact.
  • The Floci CLI binary remains installed on your machine. Docker retains the Floci image for future use.

Pause - I'll come back to this later

Shut down the Floci container to free up memory and CPU. Your local files stay on disk so you can restart later.

  • Stop the Floci emulator by running:
floci stop
  • Your emulated AWS resources (S3, CloudFront, WAF, Route 53) will be lost when the container stops. Floci uses in-memory storage by default.
  • Your local files in cdn-platform/ remain on disk. You can re-create the AWS resources by re-running the CLI commands from the project steps.

When you are ready to resume, run floci start and re-set your environment variables.

Delete - I don't want to use this again

Remove all project resources and start fresh. This stops the emulator and deletes all local project files.

  • Stop the Floci emulator by running:
floci stop
  • Delete your local project directory by running:
cd ~/Desktop
Remove-Item -Recurse -Force cdn-platform
  • Confirm the folder is gone by running:
ls

You should no longer see cdn-platform in the output.

  • Unset the AWS environment variables from your current session by running:
Remove-Item Env:AWS_ENDPOINT_URL
Remove-Item Env:AWS_DEFAULT_REGION
Remove-Item Env:AWS_ACCESS_KEY_ID
Remove-Item Env:AWS_SECRET_ACCESS_KEY

After these steps, all project traces are removed from your machine except for the Floci CLI binary and its Docker image. You can remove the Docker image through Docker Desktop if you want to reclaim additional disk space.

Nice Work!

Nice Work!

You just built a production-grade CDN infrastructure that securely delivers static assets through CloudFront with WAF protection, all running locally via Floci.

You've learned how to:

  • Deploy a CloudFront distribution secured with Origin Access Control (OAC) and compression enabled, restricting S3 access to the CDN service principal only.
  • Create a WAF v2 Web ACL with rate-limiting (1000 requests per 5 minutes per IP) and HTTP method restrictions to protect your CDN from abuse.
  • Write a GitHub Actions CI/CD workflow that automatically syncs assets to S3 and invalidates the CloudFront cache on every push to main.
  • Secret Mission: Configured a Route 53 hosted zone with alias records completing the full DNS-to-CDN request path.

Ready to quiz yourself?