Deploying my portfolio to AWS - Part 3: an architect's decisions
The deploy gets automated with no stored credentials. I add a custom domain with TLS, basic observability, and cost guardrails, justified decisions through the Well-Architected Framework pillars.
Introduction
Parts 1 and 2 left the site in this shape: private S3 bucket, CloudFront with OAC, and a local deploy automated by a script. It works, but several fronts still need work: deploys that depend on my laptop, an unprofessional URL, zero observability, and no cost guardrails.
This part closes those gaps with three architectural decisions worth explaining in depth, because each one has three or four viable alternatives and the right pick depends on the AWS Well-Architected Framework pillars: Security, Operational Excellence, Reliability, and Cost Optimization.
Decision 1: how does CI/CD authenticate to AWS?
My need: the GitHub Actions runner has to be able to run s3 sync and cloudfront create-invalidation when deploying. AWS needs credentials to authorize those calls. The question is what kind of credentials.
Alternatives considered
| Option | Mechanic | Pros | Cons |
|---|---|---|---|
| IAM user with access keys | Create keys, stash them in GitHub Secrets | Setup in 3 min, universally supported | Access keys can be compromised |
| IAM Identity Center (SSO) | Federation with a corporate identity provider | Better for multiple developers or accounts | Overkill for an individual project |
| IAM Roles Anywhere | X.509 certs on the client | Useful for on-prem workloads outside AWS | GitHub isn't the target use case |
| SAML federation | SAML 2.0 trust with an IdP | Enterprise standard | Needs an IdP, too complex for a project this size |
| OIDC (OpenID Connect), chosen | GitHub issues a JWT, AWS validates it and hands back temporary credentials | Zero persistent secrets, per-repo and per-branch granularity, full audit log | Initial setup around 15 min |
Why OIDC: through the "Security" and "Operational Excellence" lens
From the Security pillar, the canonical AWS pattern is identity federation over persistent credentials. OIDC is the specific implementation for integrating with GitHub Actions:
- Zero shared secrets. If GitHub or the repo got compromised, there's no key to leak. An attacker would need to sign valid JWTs with GitHub's private key, which is impossible without compromising GitHub's infrastructure directly.
- Ephemeral credentials. In the worst case, if they're intercepted at runtime, the exploitation window is minimal.
- CloudTrail auditing. Every AssumeRoleWithWebIdentity action is logged with the sub claim, so I know which repo, which branch, which workflow and which specific run performed the operation.
From the Operational Excellence pillar: zero maintenance, no quarterly rotation, no secret updates in multiple places.
The trust policy as defense
The trust policy of the IAM role tied to the OIDC provider is where least-privilege gets enforced at the identity layer:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<MY_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<MY_USER>/<MY_REPO>:ref:refs/heads/main"
}
}
}
Three defense layers inside the Condition:
- The Federated principal. Only tokens issued by GitHub are valid.
- The aud claim. The token has to have been issued specifically for AWS (sts.amazonaws.com). Protects against token confusion attacks, where a token valid for another service is tried against AWS.
- The sub claim. Restricts to the specific repo and branch, so a workflow running on a fork can't assume this role even if it has the ARN.
The workflow
name: Deploy to AWS (S3 + CloudFront)
on:
push: { branches: [main] }
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: npm }
- run: npm ci --ignore-scripts
- run: npm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<MY_ACCOUNT_ID>:role/<MY_ROLE_NAME>
aws-region: us-east-1
- run: aws s3 sync ./out "s3://<MY_BUCKET>" --delete --no-progress
- run: |
aws cloudfront create-invalidation \
--distribution-id "<MY_DIST_ID>" --paths "/*"
Operational note:
- cancel-in-progress: false serializes deploys. This way, two back-to-back pushes can't leave the bucket in an inconsistent state mid-sync.
Decision 2: custom domain and TLS
The default CloudFront URL (formatted as dXXXXXXXX.cloudfront.net) is functional, but terrible for marketing.
Sub-decision 2.1: where to register?
| Registrar | Year 1 | Year 2 onwards | Trade-off |
|---|---|---|---|
| Cloudflare Registrar | ~10 USD | ~10 USD | Cheaper, but DNS and registration stay separate from AWS |
| Namecheap | ~9 USD | ~13 USD | Cheap year one, goes up after |
| GoDaddy | ~2 USD (promo) | ~25 USD | Bait price followed by a jump |
| Route 53, chosen | 13 USD | 13 USD | More expensive at the start, consistent, full AWS integration |
I picked Route 53 prioritizing Operational Excellence: a single environment, fully automated ACM DNS validation, ALIAS record support (not available outside Route 53), and the option of later migrating to a more complex architecture (multi-region failover, latency-based routing) without switching DNS. The extra cost is very acceptable for those benefits.
Sub-decision 2.2: ACM cert validation
ACM offers DNS and email validation. I chose DNS because it's automatable (ACM can create the CNAMEs in Route 53 with a click) and because it enables continuous auto-renewal: as long as the CNAMEs stay in place, ACM renews the cert every year with no human intervention (Operational Excellence + Reliability). Email validation isn't viable for a brand-new domain because it requires a working mailbox at admin@, webmaster@ and similar, which doesn't exist on a freshly bought domain.
Sub-decision 2.3: cert region
CloudFront has a specific control-plane restriction: it only accepts ACM certs issued in us-east-1, despite being a global service from the data-plane perspective. So, for CloudFront, the cert always lives in us-east-1, even if your other resources are in another region.
Sub-decision 2.4: ALIAS vs CNAME at the apex
DNS RFC 1034 forbids a CNAME at the apex of a domain (that's where SOA and NS records live). The standard workaround:
| Approach | Pros | Cons |
|---|---|---|
| www as canonical, apex redirects | Compatible with any DNS | Requires extra redirect logic |
| Route 53 ALIAS | Works at the apex, free, fast resolution | Only in Route 53 |
| CNAME flattening (Cloudflare) | Works at the apex within Cloudflare DNS | Cloudflare lock-in |
| ANAME (some providers) | Same as ALIAS | Limited support |
Route 53 ALIAS sidesteps the RFC because AWS resolves the target to IPs before returning the DNS response. From the client's perspective, it just receives normal A records. It was the obvious call given I was already on Route 53.
Decision 3: observability
I need visibility on four things:
- Is anyone using it?
- Are requests completing?
- Are there anomalies?
- Am I spending more than budgeted?
CloudFront publishes two tiers. Standard is free and automatic: includes Requests, BytesDownloaded, 4xxErrorRate, 5xxErrorRate and TotalErrorRate. Additional costs 0.30 USD per distribution per month and includes CacheHitRate, OriginLatency and per-edge-location metrics.
For my scale I picked standard only, applying the four Site Reliability Engineering golden signals (Latency, Traffic, Errors, Saturation) adapted for a static CDN:
| Golden signal | Metric I use | Why standard is enough |
|---|---|---|
| Traffic | Requests, BytesDownloaded | Available on standard |
| Errors | 4xx and 5xx error rates | Available on standard |
| Latency | not measurable without additional | On this project, assuming p95 under 100 ms is fine |
| Saturation | doesn't apply to the serverless model | S3 and CloudFront scale automatically |
If I had variable user-facing latency (for example, a dynamic origin or SSR), the additional metrics would be indispensable.
Sub-decision 3.1: budget alerts as a tripwire
I set up a 5 USD monthly AWS Budget with an 80% alert. The reasoning: expected cost is around 1.70 USD per month (excluding the Route 53 domain), so 5 USD is roughly three times that, a reasonable margin for variability. The 80% threshold gives me time to investigate before things go wrong, not after. It's Reliability applied to the wallet: if some API in a loop or a broken script starts burning money, I catch it in hours, not on next month's bill.
This is one of those controls that should be mandatory on any AWS account. Takes two minutes, costs nothing, and saves you from the surprise thousand-dollar bill because someone leaked keys and mined crypto in your account, a scenario that has hit production at companies large and small.
The dashboard
A CloudWatch dashboard with six widgets organized by pillar.
- Performance and traffic: total requests (line, 7 days) and bytes downloaded (stacked area).
- Reliability: 4xx and 5xx error rate breakdown (line) and total error rate (a big number).
Cost: the first dashboard per account is free. Subsequent ones would be 3 USD per month, so not applicable here.
Architectural summary
| Well-Architected pillar | Decision | Trade-off |
|---|---|---|
| Security | OIDC with a branch-scoped trust policy | 15 min of setup vs 3 min for access keys |
| Security | TLS 1.2_2021 and OAC for S3 | Modern compatibility over legacy support |
| Operational Excellence | Route 53 + auto-renewing ACM | Around 3 USD extra per year vs an external registrar |
| Operational Excellence | Automated deploys with GitHub Actions | CI/CD setup vs manual scripts |
| Reliability | Concurrency control in the workflow | Mitigates race conditions |
| Reliability | Budget alerts | Cost defense in depth |
| Cost Optimization | Standard CloudWatch metrics | Trades detailed latency for zero cost |
| Cost Optimization | Free ALIAS records | Accepts Route 53 lock-in |
| Performance Efficiency | CloudFront global edges | High cache hit ratio implicit in static content |
Wrapping up
The complete system checks the boxes of a production-grade static site deploy: mandatory HTTPS with auto-renewing cert, global CDN with distributed cache, private bucket with OAC, keyless CI/CD via OIDC and least-privilege, basic observability, configured cost guardrails, and a proper custom domain. Total cost lands around 1.70 USD per month, roughly 20 USD per year.
Average update wall-clock time is about three minutes after git push, and zero minutes of my active attention.
Coming up: Part 4, retrospective with real data
With the infrastructure now stable, the next 30 days generate the data that matters for an honest retrospective: what the real cache hit ratio was (measurable after the fact if I turn on additional metrics temporarily), how much bandwidth I actually consumed, whether the 1.70 USD per month estimate held or drifted, whether any events triggered alerts, and what I'd change in hindsight. That analysis lands in Part 4.