AWS Builder Center

Adding a Staging Environment to My S3 + CloudFront Pipeline

How I added a separate staging environment to my S3 + CloudFront pipeline — duplicate infrastructure, branch-based GitHub Actions deploys, and a lightweight way to keep the staging URL from being publicly discoverable.

Adding a Staging Environment to My S3 + CloudFront Pipeline

My deployment pipeline so far pushes straight to production: merge to main, and the live site updates within a minute. That's great for speed, but it means every change goes live with zero chance to catch mistakes first. This article covers how I added a staging environment — a separate, password-free preview of the site before it reaches real visitors.
I'll say upfront: for a personal site with no real users depending on uptime, this is arguably overkill. I built it anyway because the goal of this series has always been learning the patterns, not just shipping the smallest possible setup — and staging environments are something I'll need to understand properly for real projects later.

What "Staging" Actually Means Here

For a static site, staging is simpler than it sounds: it's just a second, separate copy of the same infrastructure — its own S3 bucket, its own CloudFront distribution — serving whatever is on a staging branch instead of main.
There's no shared database or backend to worry about, which is exactly why this is a good project for learning the concept without the complexity that usually comes with it.

The Two Approaches I Considered

Option A: Two separate buckets and distributions One bucket/distribution for staging, one for production, each tracking a different branch.
Option B: One bucket with path-based staging A single bucket with production files at the root and staging files under a /staging/ prefix, served by the same CloudFront distribution with path-based routing.
I went with Option A. It's a bit more AWS infrastructure to manage, but it keeps production completely isolated — a misconfigured staging deploy can't accidentally affect the live site's cache behavior or permissions. Option B is cheaper and simpler, but a mistake in shared CloudFront behavior settings risks touching both environments at once.

Step 1: Duplicate the Infrastructure

I created:
  • A second S3 bucket: my-site-staging (private, accessed only via CloudFront, same Origin Access Control pattern as production)
  • A second CloudFront distribution pointing at that bucket
For staging, I deliberately did not attach a custom domain or TLS certificate at first — CloudFront gives you a default *.cloudfront.net URL out of the box, which is perfectly fine for previewing changes privately. Skipping the custom domain step here is one less thing to configure and one less thing that can be wrong.

Step 2: Restrict Access to Staging

Since the .cloudfront.net URL is still technically public if someone guesses or finds it, I added a basic layer of protection using a CloudFront Function that checks for a shared secret header, implemented as a signed cookie set after a simple password prompt. This is intentionally lightweight — not real authentication, just enough to keep the staging URL from being casually discoverable or indexed.
For a project with real stakes, this is where you'd reach for something more robust — AWS WAF with an IP allowlist, or Cognito-based authentication in front of CloudFront — but that felt like more infrastructure than this project needed right now.

Step 3: Extend the GitHub Actions Workflow

I updated the workflow to deploy to different targets depending on the branch:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
name: Deploy

on:
push:
branches: [main, staging]

permissions:
id-token: write
contents: read

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

- name: Set environment variables
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "BUCKET=${{ secrets.PROD_BUCKET_NAME }}" >> $GITHUB_ENV
echo "DISTRIBUTION_ID=${{ secrets.PROD_DISTRIBUTION_ID }}" >> $GITHUB_ENV
else
echo "BUCKET=${{ secrets.STAGING_BUCKET_NAME }}" >> $GITHUB_ENV
echo "DISTRIBUTION_ID=${{ secrets.STAGING_DISTRIBUTION_ID }}" >> $GITHUB_ENV
fi

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::YOUR-ACCOUNT-ID:role/github-actions-deploy
aws-region: us-east-1

- name: Sync files to S3
run: aws s3 sync ./site s3://$BUCKET --delete

- name: Invalidate CloudFront cache
run: aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/*"
The key change is picking the bucket and distribution ID at runtime based on which branch triggered the workflow, instead of hardcoding a single target.

Step 4: Widen the IAM Policy — Carefully

The IAM role used by this workflow now needs access to two buckets and two CloudFront distributions instead of one. I updated the policy's Resource arrays to include both ARNs explicitly, rather than loosening the policy to something broader like a wildcard across all buckets. It's a small amount of extra typing in exchange for keeping the scope exactly as tight as before.

Step 5: My New Workflow

With this in place, my actual day-to-day process became:
  1. Make changes, commit, push to staging
  2. Visit the staging CloudFront URL, check everything looks right
  3. If it's good, merge staging into main
  4. Production updates automatically
It's a small amount of extra ceremony for a personal site, but it's also exactly the shape of workflow used in real production systems, just scaled down — which was the point of building it.

What I'd Reconsider for a Bigger Project

Being honest about the limits of this setup:
  • No automated testing — staging here only catches things I notice by eye. A real pipeline would run link checks, accessibility checks, or visual regression tests before even reaching staging.
  • Manual promotion — merging staging into main by hand is fine at this scale, but larger teams often use pull-request-based promotion with required approvals.
  • Lightweight staging protection — a shared-secret cookie is enough to deter casual discovery, not a real access control system. Worth replacing with WAF or Cognito before this pattern touches anything with real data.

What I Learned

Environments are mostly about blast radius. The entire point of staging isn't "a second place to look at the site" — it's limiting the damage a mistake can do by giving it somewhere to land before it reaches real users.
Duplication has a cost, and that's often fine. It felt slightly wasteful at first to have two of everything for a project this small. But the alternative — shared infrastructure with conditional logic inside it — tends to be harder to reason about, not easier.
A workflow that branches on github.ref_name is a small pattern with big reach. The same one-workflow-multiple-environments shape shows up constantly in real CI/CD systems, just with more environments and more conditions.

What's Next

I'm planning to look at monitoring and alerting next — specifically CloudWatch alarms for unexpected spikes in CloudFront errors or billing, since right now I'd have no idea if something broke unless I happened to check the site myself.
If you've set up staging for a static site differently, or think the shared-secret approach is more (or less) than this project needs, I'd like to hear it.

Tags: #AWS #StagingEnvironment #CloudFront #S3 #GitHubActions #CloudComputing #Beginner #LearningInPublic
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article