In the previous post, I laid out the selection process for the technologies I’d use for this blog. I settled on using Astro for my static site generation, S3 as my “Web Server”, and Cloudflare for my DNS/CDN/WAF/Certificates/Analytics.
In this tutorial, we will set up a low-cost static site using these technologies that can easily scale.
We’ll also be version-controlling our site and using CI/CD to publish it when changed. In this tutorial, we’ll be using git for version control and GitHub for remote hosting and CI/CD.
Note: Updated Mar 29, 2025, with more info about how S3 error code pricing works and using Full (Strict) SSL encryption mode.
Website Generation
This tutorial isn’t specific to any one framework, so the generation of your actual site will be up to you. If you are using any framework, though, there is likely a concept of a production release. For static site generators, this will mean compiling your site into pure HTML/CSS, minimized JavaScript, and supporting fonts/images/etc.
If you don’t have a site started yet, Astro is a great Jamstack framework with many free themes available. There are plenty of other options for static site generators, though.
Take some time to read through your framework or template’s documentation to learn more. Here is a tutorial for building Astro sites, for example.
GitHub Setup (Part 1)
If you don’t have a GitHub account, go ahead and set one up on GitHub and make sure to set up MFA.
Then, go ahead and create a repo and make your first commit with your website’s source and push it to GitHub.
We’ll set up our GitHub Actions in Part 2 to automatically build and upload our site to AWS S3 when we make a change.
AWS Setup
Now let’s go and create a new AWS account if you don’t have one. If you do have one, you should create a new account to separate this site from other projects.
You can skip setting up Multi-Factor Authentication for up to 35 days, but don’t. AWS accounts without MFA are the favorite prey of botnets, crypto miners, etc. You don’t want to be yet another story of someone getting a surprise multi-thousand dollar bill.
If this is your first time using AWS, make sure before you take any action you check the AWS cost calculator. There are a lot of things that fall under the free tier, but many things in this tier expire after 12 months and pricing can change.
For example, AWS S3, which we will use in the tutorial, changed how they handle pricing for some error codes in May 2024.
You can also follow the linked tutorial to set up a free billing alarm to avoid any surprise charges.
In the next few steps, we’re going to set up our AWS Infrastructure manually as opposed to using an Infrastructure as Code tool like CDK, Terraform, or CloudFormation. In a later post, we will do just that with Terraform, but here are the manual steps for those not interested.
S3 Bucket Origin Server
For this tutorial, we are going to be using AWS S3 (Simple Storage Service) as our origin web server for our CDN, Cloudflare. Azure, Google, Cloudflare, and many others offer analogous blob store constructs with the same capabilities if they fit your needs better as well.
In the console, go to the S3 page and before creating a bucket, change the AWS region to one that isn’t N. Virginia (us-east-1).
I’d choose one inside your own country to reduce latency when uploading content or one that is closest to your main user base. Using us-east-1 is ill-advised as it’s AWS’s primary region and has a track record of outages that don’t affect other regions. That being said, many global AWS services rely on us-east-1, so you can never truly isolate yourself, but you can limit your risk.
Ok, now create a new bucket and give it a unique name. I chose a random string for mine. Uncheck “Block all public access” and confirm you understand what you are doing. Then create your bucket.
Next, go to Permissions and we are going to change the “Bucket policy” to allow reading of objects from Cloudflare IPs.
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowCloudFlareIPs", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::<your_bucket_name>/*", "Condition": { "IpAddress": { "aws:SourceIp": [ "173.245.48.0/20", ... "2c0f:f248::/32" ] } } } ]}This policy will allow the listed IPv4 and IPv6 addresses to access and read any object in this bucket. You can get the full list of IPs for Cloudflare here: cloudflare.com/ips.
Make sure to also change <your_bucket_name> to the name of your bucket and add all of the Cloudflare IPs.
Cloudflare exposes these IPs via an API as well, so in a later post when we set up our S3 bucket via Terraform, we’ll automate this process so it stays up to date with zero effort.
Finally, we are going to go under “Properties” of the bucket and go to “Static website hosting”. Enable it and set the index document to “index.html”.
You can also set a custom error response document by setting a 404.html file, but this does mean you will be charged for 404s.
For buckets configured with website hosting, applicable request and other charges will still apply when S3 returns a custom error document or for custom redirects.
Note: If you want to set Cloudflare’s SSL mode to Full (strict), you don’t need to turn on Static website hosting.
IAM Credentials to update your site
Now we need some way to upload our site to this S3 bucket. We are going to automate this later in this tutorial using GitHub Actions.
In the AWS console, go to the IAM page and then select Policies and then “Create Policy”. Switch to the JSON editor and you can paste in the following policy, replacing the bucket name with your own:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "MyS3EditorPolicy", "Effect": "Allow", "Action": ["s3:DeleteObject", "s3:GetBucketLocation", "s3:GetObject", "s3:ListBucket", "s3:PutObject"], "Resource": ["arn:aws:s3:::<your_bucket_name>/*", "arn:aws:s3:::<your_bucket_name>"] } ]}You should read more about the permissions we are granting if you aren’t familiar. In essence, though, this policy allows the holder to Read/Write/Delete anything in the bucket.
Now we are going to set up OpenID Connect (OIDC) to allow GitHub Actions to assume a role with the policy we just created using short-lived credentials. This limits the risk of compromise compared to long-lived credentials like IAM User Access Keys.
Go back to the AWS console IAM page and select “Identity providers” and then select “Add Provider”.
Switch from “SAML” to “OpenID Connect” and set the following options:
- Provider URL: https://token.actions.githubusercontent.com
- Audience: sts.amazonaws.com
Finish adding the provider and then go to the provider and select “Assign Role”. You should get a GitHub-specific “Web identity” page to fill in. Select the audience we set above and if you want to automate creating the role trust policy, fill in the other fields for your GitHub repo you will deploy from.
If you didn’t fill in your GitHub repo, you’ll have to add it now to the trust policy as shown below. If you did add it, this should be filled out for you.
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::<accountId>:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:sub": "repo:<org>/<repo>:ref:refs/heads/<branch>", "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" } } } ]}Finally, add the IAM policy we created above, name the role, and finish creation. We will need the ARN of this role when we set up the GitHub Action later, but otherwise should be done with the AWS Console.
GitHub and AWS have more detailed tutorials on OIDC if you run into trouble as well.
Cloudflare setup
Now it’s time to create a Cloudflare account and be sure to enable MFA.
After doing this, you can go to Domain Registration > Register Domains and start figuring out what is available and what you are willing to pay. This blog’s domain, for example, was about $10 per year.
Now we have a domain, we can start to configure our DNS and other settings to ensure we are minimizing calls to S3 and routing users correctly.
In a later blog post, we’ll migrate our Cloudflare setup to Terraform, so jump ahead if you want to do the same. I’ll include the manual setup here for those not interested, though.
DNS
Create a CNAME record with the name of your site, e.g., bhog.dev, and link it to any site name (e.g., example.com). Make sure to leave this as a “Proxied” record so we can redirect it to your actual site later in this tutorial.
You should also add a “Proxied” A type record for www at this time and link it to any IP (e.g., 192.0.2.1) as a stand-in for now. We’ll add a redirect rule for it later. You can also do the same thing for m if you don’t have a dedicated mobile site.
Email Spoofing
Unless you have plans to use your domain for email, you should follow the recommendations in the DNS console to prevent email spoofing.
When complete, your email spoofing rules should look similar to this if you choose not to use your domain for email at this time.
;; TXT Records<your site>. 1 IN TXT "v=spf1 -all"_dmarc.<your site>. 1 IN TXT "v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s;"*._domainkey.<your site>. 1 IN TXT "v=DKIM1; p="Rules
Under the Overview, add a new Redirect rule for www. You can either redirect all traffic from your root to www or redirect www to your root. You can also set up mobile-specific redirects here. Since I don’t have a mobile page, I just redirect m to root.
Under the Cloud Connector tab, create a new connector. This will be what actually connects your proxied CNAME record to your site. If using the static website hosting option, you can go to the properties tab and grab the URL. If you are not using this option, see the note below.
Ex: <bucket>.s3-website-<region>.amazonaws.com.
Set the connector to match all incoming requests and your site should now be live. You can now put an example index.html in your S3 bucket if you want to test things out before your actual site is uploaded to S3.
Ex:
<!DOCTYPE html><html lang="en"> <head> <title>My Website</title> </head> <body> <main> <h1>Welcome</h1> </main> </body></html>Note: If you are using S3 static hosting, you can skip to the next section
If you are not using S3 static website hosting, you will use the bucket URL instead. Ex: <bucket>.s3.amazonaws.com. You’ll still set this to match all incoming requests as well.
Without the static hosting, we also have to create some rewrite rules ourselves so our paths resolve to our index.html for at least the root domain. Depending on your internal routing, you may need more rewrite rules.
To redirect your root (e.g., bhog.dev) and domains with a trailing slash (e.g., bhog.dev/), create 2 new rules. Set this to match on a custom filter expression for the URI Path ending in /. Select Rewrite to for the parameters and use a Dynamic rewrite with the following rule:
concat(http.request.uri.path, "index.html")You can then do the same with another rule for your root. Use the URI Path equal to blank and do the same settings, but for the rewrite rule use:
concat(http.request.uri.path, "/index.html")If all of your page URLs besides your root always have a trailing slash, your work here should be done. Astro has a handy trailingSlash setting to enforce this on builds, so you can verify you are. If they don’t, you might need some more work here to get everything set up correctly.
One downside to note using S3’s static hosting is you can’t configure a custom error document if that is a must for you.
SSL/TLS
Configure encryption mode to Full or Full (Strict) if not using S3 static hosting. This ensures that when users connect to your site using HTTPS, the connection to your origin is secure. If you aren’t using strict SSL, users can still cause the connection to the origin to use HTTP, but we’ll fix this next.
Open Edge Certificates and toggle the following on if not already enabled:
- Always Use HTTPS
- Automatic HTTPS Rewrites
This will ensure any users using unsecured HTTP are forced to use HTTPS and you don’t have any plaintext traffic.
Security
Go to the WAF tab and add some Custom rules. These are important because malicious scanners will hit your site daily, wasting calls to your origin.
A good start for a static site generated by Astro might be blocking the following:
(http.request.uri.path contains ".env") or(http.request.uri.path contains ".yml") or(http.request.uri.path contains ".json") or(http.request.uri.path contains ".yaml") or(http.request.uri.path contains ".aws") or(http.request.uri.path contains ".py") or(http.request.uri.path contains ".php") or(http.request.uri.path contains "meta-data") or(http.request.uri.path contains "wp-config") or(http.request.uri.path contains "metadata") or(http.request.uri.path contains "user-data") or(http.request.uri.path contains ".git") or(http.request.uri.path contains "phpinfo") or(http.request.uri.path contains "env.backup") or(http.request.uri wildcard r"/api/*") or(http.request.uri.path contains "wp-includes") or(http.request.uri.path wildcard r"/config/*") or(http.request.uri.path contains ".config") or(http.request.uri.path contains ".properties") or(http.request.uri.path contains ".prop")Make sure to change and update this list for your needs, as well as watching your traffic and adding other rules as needed. On the other hand, you might add a post or file that triggers these rules and you’ll have to adjust them to allow it.
Remember, with S3 static hosting and a custom error response, every 404 that isn’t mitigated hits your S3 bucket and causes charges to your account. In that setup, it is very important to check in and adjust if you see a ton of traffic to your origin.
When not using custom error responses or S3 static hosting, you won’t be charged for these requests, but you can still configure them to save a trip to your origin.
Bots
Go to the Bots tab and turn on Bot Fight Mode unless you have a good reason not to.
Caching
Go to the Cache Rules tab and create at least one new rule. Caching efficiently while ensuring updated content can be pretty complex depending on your site.
For my initial rule, I apply it to all requests and set my Edge TTL to ignore the cache-control header and use a long TTL (+1 month). Note this will mean any change to existing assets will require you to purge it manually from the Configuration tab or wait for the TTL to pass.
You can do a lot with the browser TTL too, which we won’t get into in this post. I’d ensure you read through the Cloudflare caching docs even if you don’t read any other docs.
Finally, go to “Tiered Cache” and turn on “Smart Tiered Caching Topology”.
GitHub Setup (Part 2)
I’ve provided manual steps for AWS and Cloudflare since, for the most part, they should be “set it and forget it”. Still, spend some time with your WAF and Caching rules though!
Your website is not the same as you’ll be regularly adding content to it. Beyond that, unless you use pure HTML and CSS, your build tools and frameworks will be getting regular updates you should be pulling in and adjusting your code for.
So for this part, we’ll be using GitHub Actions, which provides CI/CD tooling around your repo. Our CI/CD will break down to the following steps:
- Checkout the repo
- Using actions/checkout
- Get our AWS CLI configured with short-lived credentials to our S3 bucket
- Build
- This will be specific to your site. If pure HTML/CSS, you won’t have any. Given below is a build example using pnpm/action-setup and actions/setup-node
- Sync your site’s build directory to S3
- Using the AWS S3 Sync CLI command
Make sure to occasionally check for any updates to the action packages you use. This will help you avoid your build suddenly breaking. I’m using the latest version of all actions at the time of publishing, but when you’re reading, they have likely been updated.
If you have anything sensitive you might need in your GitHub Actions file, make sure to place it into a GitHub Secret. I chose to put my bucket name and AWS account id into secrets in case I were to ever make my repo public.
Finally, this particular deployment is configured to run on any git push, but you can make this a lot more granular. This can be important if you plan to push to your GitHub repo often but don’t want to push updates to your site every time, wasting AWS S3 resources for no visible change.
GitHub Actions File
Now you understand what this file is doing, you can customize it to your needs and, when ready, commit it to your GitHub repo in the following location: .github/workflows/deploy.yml
name: Deployon: [push]
env: AWS_REGION: <s3BucketRegion>
permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout
jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup AWS CLI uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::${{ secrets.ACCOUNT_ID }}:role/<roleName> role-session-name: GitHub_to_AWS_via_FederatedOIDC aws-region: <s3BucketRegion> - name: Install pnpm uses: pnpm/action-setup@v4 with: version: 10 - name: Install Node.js uses: actions/setup-node@v4 with: node-version: 23 cache: 'pnpm' - name: Install NPM dependencies run: pnpm install - name: Build run: pnpm build - name: Sync files to S3 bucket run: | aws s3 sync <websiteBuildDir> s3://${{ secrets.BUCKET_NAME }} --delete --metadata-directive REPLACE --cache-control max-age=31556926,publicConclusion
Your CI/CD should have run when you pushed your deployment YAML, and you’ll be able to see it from the GitHub Actions tab and troubleshoot any issues. Your site should be available from your domain name now, caching your pages and mitigating most attacks. Make sure you purge any files you may have used when testing and setting things up. This setup should result in a low number of requests to your AWS S3 origin monthly and ideally zero to no cost beyond your domain registration.