<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ayodeji Adeboyejo's blog]]></title><description><![CDATA[Ayodeji Adeboyejo's blog]]></description><link>https://ayo-adeboyejo.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Ayodeji Adeboyejo&apos;s blog</title><link>https://ayo-adeboyejo.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 14 Oct 2026 12:37:36 GMT</lastBuildDate><atom:link href="https://ayo-adeboyejo.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why My terraform apply Just Hung: Lessons from My First Real Terraform Project]]></title><description><![CDATA[Watching someone else's terraform apply scroll past in a tutorial only teaches you so much. Eventually I closed the tab, opened a blank folder, and chose to learn by making my own mistakes instead of ]]></description><link>https://ayo-adeboyejo.hashnode.dev/why-my-terraform-apply-just-hung-lessons-from-my-first-real-terraform-project</link><guid isPermaLink="true">https://ayo-adeboyejo.hashnode.dev/why-my-terraform-apply-just-hung-lessons-from-my-first-real-terraform-project</guid><category><![CDATA[Devops]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[AWS]]></category><category><![CDATA[AWS s3]]></category><category><![CDATA[aws cloudfront]]></category><category><![CDATA[aws-route53]]></category><dc:creator><![CDATA[Ayodeji Adeboyejo]]></dc:creator><pubDate>Sun, 02 Aug 2026 21:35:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a6db9175590e771838debb3/7fd621ca-bbae-4940-87ca-94da86ead5a8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Watching someone else's terraform apply scroll past in a tutorial only teaches you so much. Eventually I closed the tab, opened a blank folder, and chose to learn by making my own mistakes instead of reading about other people avoiding theirs. The project I settled on was modest enough to finish in a weekend, yet grounded in some AWS services organisations rely on daily: a static website hosted on S3, delivered through CloudFront, and reached through a custom domain configured in Route 53. That mix of scale and substance is what made the experience worthwhile.</p>
<h2>What I was actually building</h2>
<p>CloudFront sits as the only public entry point in front of a private S3 bucket. Origin Access Control makes sure only the CloudFront distribution is allowed to read from it. Route 53 then maps a real domain name onto that CloudFront distribution, so visitors type <code>www.yourdomain.com</code> instead of some random <code>.cloudfront.net</code> URL.</p>
<pre><code class="language-plaintext">User types yourdomain.com
        │
        ▼
   Route 53 (DNS)
        │
        ▼
CloudFront Distribution
        │
        ▼
S3 Bucket (private — no public access)
</code></pre>
<p>The bucket, the access policy, the CDN, the DNS, and other resources were all defined in Terraform, and the whole stack came up with a couple of <code>terraform apply</code> runs.</p>
<h2>The first wall I hit: A 403 on my custom domain</h2>
<p>Once I had the S3 bucket and CloudFront distribution up, the auto-generated CloudFront URL worked fine. But the moment I tried hitting it through my actual domain, I got a 403.</p>
<p>It turns out CloudFront only answers requests for hostnames explicitly listed in its <code>aliases</code> config. Anything else gets rejected before it even reaches the origin. I was missing two things: the <code>aliases</code> block telling CloudFront "yes, accept requests for this domain," and an ACM certificate to handle HTTPS for it.</p>
<p><strong>Code that caused the 403: missing</strong> <code>aliases</code> <strong>and</strong> <code>viewer_certificate</code> <strong>on CloudFront</strong></p>
<pre><code class="language-hcl">resource "aws_cloudfront_distribution" "main_s3_distribution" {
  origin {
    domain_name              = aws_s3_bucket.main.bucket_regional_domain_name
    origin_id                = local.s3_origin_id
    origin_access_control_id = aws_cloudfront_origin_access_control.main.id
  }

  enabled             = true
  default_root_object = "index.html"

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = local.s3_origin_id
    viewer_protocol_policy = "redirect-to-https"

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true    # ← no custom certificate
  }
                                             # ← no aliases block
}
</code></pre>
<p>The fix was straightforward once I understood the <em>why</em>: request the certificate, attach it via <code>viewer_certificate</code>, add the domain to <code>aliases</code>. Simple change, but it took actually hitting the error to understand what CloudFront was protecting against.</p>
<p><strong>Code that fixed the 403: added</strong> <code>aliases</code> <strong>and</strong> <code>viewer_certificate</code> <strong>with ACM</strong></p>
<pre><code class="language-hcl">resource "aws_cloudfront_distribution" "main_s3_distribution" {
  origin {
    domain_name              = aws_s3_bucket.main.bucket_regional_domain_name
    origin_id                = local.s3_origin_id
    origin_access_control_id = aws_cloudfront_origin_access_control.main.id
  }

  enabled             = true
  default_root_object = "index.html"

  aliases = [var.domain_name]              # ← tells CloudFront to accept requests
                                           #   for your custom domain

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = local.s3_origin_id
    viewer_protocol_policy = "redirect-to-https"

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.main.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }                                        # ← custom ACM certificate attached
}
</code></pre>
<hr />
<h2>The bigger problem: why my <code>terraform apply</code> just... hung</h2>
<p>My domain is registered with a third-party registrar (not Route 53), and when ACM issues a certificate, it validates ownership by writing a CNAME record into the Route 53 hosted zone and then polling DNS for it publicly.</p>
<p>Here's the catch: that DNS query only reaches Route 53 if the domain's nameservers at the registrar are actually pointed at Route 53. If they're not, ACM's validation query hits the registrar's default nameservers instead, which have no idea what CNAME record it's looking for. The certificate just sits in <code>PENDING_VALIDATION</code> forever, and <code>terraform apply</code> hangs right along with it, blocked on this resource:</p>
<pre><code class="language-hcl">
  certificate_arn         = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
</code></pre>
<p>The fix was splitting the deployment into two stages instead of one.</p>
<p><strong>Stage one</strong> — provision just the Route 53 hosted zone:</p>
<pre><code class="language-bash">terraform init
terraform validate
terraform apply -target=aws_route53_zone.main_route
</code></pre>
<p>Then grab the four nameservers Route 53 assigned:</p>
<pre><code class="language-bash">terraform output route53_nameservers
</code></pre>
<p>...and go update them at the registrar. Then wait (anywhere from 5 minutes to 48 hours) and confirm propagation:</p>
<pre><code class="language-bash">nslookup -type=NS yourdomain.com
</code></pre>
<p><strong>Stage two</strong> — once the nameservers have actually propagated, run the full apply:</p>
<pre><code class="language-bash">terraform plan
terraform apply
</code></pre>
<p>This time ACM's validation query lands on Route 53, finds the CNAME record Terraform already wrote there, and the certificate issues cleanly. Terraform waits for it to hit <code>ISSUED</code> before attaching it to CloudFront, then finishes provisioning the other resources.</p>
<p>Not glamorous, but it's the kind of thing you only really understand after watching it fail once.</p>
<h2>A few other things that clicked</h2>
<p><strong>CNAME vs. alias A record.</strong> The obvious choice for pointing a domain at CloudFront's domain name looks like a CNAME. It is not allowed at the zone apex, which is the root domain, so that option is off the table from the start. Route 53's alias A record solves this properly: it maps directly to an AWS resource instead of a raw IP, works at the root domain, resolves inside AWS's own DNS, and does not cost extra in Route 53 query charges. Once that became clear, the choice was obvious.</p>
<p><strong>OAC over OAI.</strong> CloudFront used to lock down S3 access with something called Origin Access Identity, basically a special CloudFront user added to the bucket's ACL. It is being phased out in favour of Origin Access Control, which uses proper IAM style signed requests (SigV4), works across all S3 regions, and supports KMS encryption. The bucket policy also ties access to one specific distribution's ARN, rather than any CloudFront distribution somewhere, a meaningfully tighter setup for barely any extra configuration.</p>
<h2>What the finished setup actually does</h2>
<ul>
<li><p>Remote state locking means I am not going to accidentally overwrite my own state file mid apply.</p>
</li>
<li><p>The S3 bucket stays fully private. No public bucket policy, only the CloudFront distribution I created can read from it, enforced through a least privilege policy scoped to CloudFront's service principal plus a <code>SourceArn</code> condition.</p>
</li>
<li><p>Website files upload automatically from a local <code>www/</code> folder, with Terraform working out the correct <code>content_type</code> for each file extension.</p>
</li>
<li><p>HTTPS is enforced on every request through <code>redirect to https</code>.</p>
</li>
</ul>
<h2>Final thoughts</h2>
<p>None of these problems were exotic on paper. A 403 on a custom domain, a certificate stuck validating, a DNS record type that seemed obvious until it was not. But reading about them in a checklist and actually watching them happen on my own infrastructure are two different educations. One tells you what to do. The other teaches you why it matters, and that difference is what made the pieces of this project actually stick.</p>
<p>This project touched IAM, ACM, S3, CloudFront, and Route 53, and it made me understand how they depend on each other, not just how to spin them up. That understanding is the part that carries over when the infrastructure gets bigger and the stakes get higher.</p>
<p>Full code, with the actual Terraform files, is here: <a href="https://github.com/ayo-adeboyejo/aws-terraform-projects/tree/main/aws-s3-cloudfront-webhosting-terraform">aws-s3-cloudfront-webhosting-terraform</a></p>
]]></content:encoded></item></channel></rss>