The Problem with Traditional CI/CD
Most tutorials for deploying static sites to AWS tell you to:
- Generate AWS access keys
- Store them as GitHub secrets
- Hope nobody compromises your repository
This approach has serious security implications. Long-lived credentials in CI/CD pipelines are a common attack vector since if your repository is compromised or secrets are accidentally exposed, attackers gain full AWS access with those credentials.
There's a better way: OpenID Connect (OIDC) federation.
What I Built
A fully automated static website deployment pipeline that eliminates access keys entirely by using temporary credentials exchanged through OIDC.
Live Demo: https://d2jgqhup9totr6.cloudfront.net
Source Code: GitHub Repository
Architecture Overview
Developer commits → GitHub Actions → OIDC token exchange
→ Temporary AWS credentials → Deploy to S3 → CloudFront invalidation
Key Components:
- S3 for static website hosting
- CloudFront for global CDN
- Terraform for infrastructure as code
- GitHub Actions for CI/CD
- OIDC for secure authentication
Why OIDC Over Access Keys?
Traditional Approach (Access Keys):
# Potential security risk
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Problems:
- Keys are long-lived (valid until manually rotated)
- If leaked, provide persistent AWS access
- Require manual rotation and management
- Stored as static secrets in GitHub
OIDC Approach:
# This is secure
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
Benefits:
- Credentials expire in 1 hour (temporary)
- No static secrets to leak
- Zero credential rotation needed
- AWS manages the trust relationship
Implementation Deep Dive
1. Setting Up the OIDC Provider in Terraform
First, create an OIDC identity provider that trusts GitHub:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
The thumbprint verifies GitHub's signing certificate authenticity.
2. Creating the IAM Role with Trust Policy
For this to work, we define exactly which repositories can assume this role:
resource "aws_iam_role" "github_actions" {
name = "github-actions-s3-deployment"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:${var.github_username}/${var.repo_name}:ref:refs/heads/main"
}
}
}]
})
}
Critical security feature: The StringLike condition restricts access to:
- Your specific GitHub username
- Your specific repository
- Only the
mainbranch
Even if someone forks your repo, they can't assume this role.
3. Least-Privilege IAM Permissions
The role only gets permissions it absolutely needs:
resource "aws_iam_role_policy" "github_actions" {
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
]
Resource = [
aws_s3_bucket.website_storage.arn,
"${aws_s3_bucket.website_storage.arn}/*"
]
},
{
Effect = "Allow"
Action = "cloudfront:CreateInvalidation"
Resource = aws_cloudfront_distribution.website_storage.arn
}
]
})
}
No overly broad permissions.
4. CloudFront Configuration Challenge
Here's something that tripped me up: S3 website endpoints require custom origin configuration, not standard S3 origin configuration.
# This doesn't work for S3 website endpoints
origin {
domain_name = aws_s3_bucket.website_storage.bucket_regional_domain_name
origin_id = "S3-Website"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.default.cloudfront_access_identity_path
}
}
# This works for the s3 website endpoints
origin {
domain_name = aws_s3_bucket.website_storage.website_endpoint
origin_id = "S3-Website"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "http-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
Why? S3 website endpoints function as HTTP servers, supporting redirects and error documents. Standard S3 origins access the S3 API directly and don't support these features.
5. Remote State Management
For production infrastructure, always use remote state with locking:
terraform {
backend "s3" {
bucket = "escanut-tf-state"
key = "s3-website/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-state-lock"
encrypt = true
}
}
This will enable:
- Team collaboration without conflicts
- State encryption at rest
- Prevention of concurrent modifications
The GitHub Actions Workflow
The deployment workflow is remarkably simple:
name: Deploy to S3
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Sync to S3
run: |
aws s3 sync ./build s3://${{ secrets.S3_BUCKET }} --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_ID }} \
--paths "/*"
- name: Deployment Overview
run: |
echo "Website deployment successful"
echo "https://${{ secrets.CLOUDFRONT_URL }}"
Deployment time: Approximately 30 seconds from commit to live site (measured from workflow logs).
Cost Analysis
One of the best parts? This infrastructure costs almost nothing to run.
Monthly costs (based on January 2026 AWS pricing):
| Service | Usage | Cost |
|---|---|---|
| S3 Storage | <1GB | $0.02 |
| S3 Requests | 10k/month | $0.05 |
| CloudFront | 10GB transfer | $0.85 |
| Total | ~$0.92/month |
For comparison, a single t3.micro EC2 instance costs $8-15/month and requires active management.
.
AWS's eventually consistent nature means public access settings must be applied before the bucket policy.
Security Wins
This architecture eliminates several attack vectors:
No long-lived credentials - temporary credentials expire in 1 hour
Repository-scoped access - only your specific repo can deploy
Branch-specific trust - only main branch can trigger deployments
Least-privilege permissions - role can only write to specific S3 bucket
Encrypted state - Terraform state encrypted at rest in S3
No credential rotation - AWS handles credential lifecycle automatically
The current implementation shows:
- Infrastructure as Code proficiency
- Cloud security best practices
- CI/CD automation
- Cost optimization awareness
Conclusion
OIDC federation with GitHub Actions is the modern standard for CI/CD authentication to AWS. It's more secure, requires zero maintenance, and is easier to implement than managing long-lived access keys.
If you're still using AWS access keys in your deployment pipelines, it's time to upgrade.
Try it yourself: Fork the repository, update the variables, and deploy your own infrastructure in under 5 minutes.
Drop a comment below or reach out on LinkedIn.
If this was helpful, consider giving the GitHub repo a ⭐!
Top comments (0)