Part 3/42026-06-30

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.

AWSOIDCGitHub ActionsRoute 53Well-Architected

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

OptionMechanicProsCons
IAM user with access keysCreate keys, stash them in GitHub SecretsSetup in 3 min, universally supportedAccess keys can be compromised
IAM Identity Center (SSO)Federation with a corporate identity providerBetter for multiple developers or accountsOverkill for an individual project
IAM Roles AnywhereX.509 certs on the clientUseful for on-prem workloads outside AWSGitHub isn't the target use case
SAML federationSAML 2.0 trust with an IdPEnterprise standardNeeds an IdP, too complex for a project this size
OIDC (OpenID Connect), chosenGitHub issues a JWT, AWS validates it and hands back temporary credentialsZero persistent secrets, per-repo and per-branch granularity, full audit logInitial 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:

  1. 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.
  2. Ephemeral credentials. In the worst case, if they're intercepted at runtime, the exploitation window is minimal.
  3. 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?

RegistrarYear 1Year 2 onwardsTrade-off
Cloudflare Registrar~10 USD~10 USDCheaper, but DNS and registration stay separate from AWS
Namecheap~9 USD~13 USDCheap year one, goes up after
GoDaddy~2 USD (promo)~25 USDBait price followed by a jump
Route 53, chosen13 USD13 USDMore 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:

ApproachProsCons
www as canonical, apex redirectsCompatible with any DNSRequires extra redirect logic
Route 53 ALIASWorks at the apex, free, fast resolutionOnly in Route 53
CNAME flattening (Cloudflare)Works at the apex within Cloudflare DNSCloudflare lock-in
ANAME (some providers)Same as ALIASLimited 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:

  1. Is anyone using it?
  2. Are requests completing?
  3. Are there anomalies?
  4. 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 signalMetric I useWhy standard is enough
TrafficRequests, BytesDownloadedAvailable on standard
Errors4xx and 5xx error ratesAvailable on standard
Latencynot measurable without additionalOn this project, assuming p95 under 100 ms is fine
Saturationdoesn't apply to the serverless modelS3 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 pillarDecisionTrade-off
SecurityOIDC with a branch-scoped trust policy15 min of setup vs 3 min for access keys
SecurityTLS 1.2_2021 and OAC for S3Modern compatibility over legacy support
Operational ExcellenceRoute 53 + auto-renewing ACMAround 3 USD extra per year vs an external registrar
Operational ExcellenceAutomated deploys with GitHub ActionsCI/CD setup vs manual scripts
ReliabilityConcurrency control in the workflowMitigates race conditions
ReliabilityBudget alertsCost defense in depth
Cost OptimizationStandard CloudWatch metricsTrades detailed latency for zero cost
Cost OptimizationFree ALIAS recordsAccepts Route 53 lock-in
Performance EfficiencyCloudFront global edgesHigh 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.