<?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" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Bobby Abu | DevOps on Medium]]></title>
        <description><![CDATA[Stories by Bobby Abu | DevOps on Medium]]></description>
        <link>https://medium.com/@ozort?source=rss-7e177ee486ef------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*IBGgGFpMPlyRDT8JcSJ_wA.jpeg</url>
            <title>Stories by Bobby Abu | DevOps on Medium</title>
            <link>https://medium.com/@ozort?source=rss-7e177ee486ef------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 04:03:36 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@ozort/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Deploying a Static Website on AWS EC2 with Terraform: A Step-by-Step Walkthrough]]></title>
            <link>https://medium.com/@ozort/deploying-a-static-website-on-aws-ec2-with-terraform-a-step-by-step-walkthrough-2ba2edc0d32e?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/2ba2edc0d32e</guid>
            <category><![CDATA[aws-ec2]]></category>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[aws-ec2-instance]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[aws]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Thu, 15 Oct 2026 13:52:10 GMT</pubDate>
            <atom:updated>2026-10-15T13:52:10.191Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-AvmGPkcOfIeqFl7MkZ27w.png" /></figure><p>I wanted a hands-on project that ties together networking, compute, and Infrastructure as Code (IaC), and a static website on EC2 is a good small-scale way to do that. This walkthrough shows how I built it with Terraform, from an empty folder to a live site, with no clicking around the AWS console.</p><h3>What we’re building</h3><p>A single Nginx web server on EC2, inside a custom VPC, with a fixed public IP, all defined in Terraform.</p><pre>Browser → Elastic IP → Internet Gateway → Route Table → Public Subnet<br>        → Security Group (port 80) → EC2 → Nginx → index.html</pre><p><strong>Resources created:</strong> VPC, public subnet, internet gateway, route table and association, security group, EC2 instance, and an Elastic IP.</p><h3>Prerequisites</h3><ul><li>An AWS account</li><li>AWS CLI installed and configured (aws configure)</li><li>Terraform 1.5 or newer installed</li><li>A code editor (I used VS Code)</li></ul><p>Check your setup:</p><pre>terraform -version<br>aws sts get-caller-identity</pre><h3>Step 1: Create the project folder</h3><pre>mkdir tf-static-site<br>cd tf-static-site</pre><p>Create these files inside it:</p><pre>tf-static-site/<br>├── main.tf<br>├── outputs.tf<br>└── user_data.sh</pre><h3>Step 2: Configure the provider</h3><p>Open main.tf and start with the Terraform block and AWS provider:</p><pre>terraform {<br>  required_providers {<br>    aws = {<br>      source  = &quot;hashicorp/aws&quot;<br>      version = &quot;~&gt; 5.0&quot;<br>    }<br>  }<br>}<br><br>provider &quot;aws&quot; {<br>  region = &quot;us-east-1&quot;<br>}</pre><p>Pinning the provider version keeps your runs reproducible. A new provider release can’t quietly change behavior.</p><h3>Step 3: Look up the latest Amazon Linux AMI</h3><p>Instead of hardcoding an AMI ID, which differs per region and goes stale, ask AWS for the current one:</p><pre>data &quot;aws_ssm_parameter&quot; &quot;al2023&quot; {<br>  name = &quot;/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64&quot;<br>}</pre><p>A data source reads existing information. It creates nothing.</p><h3>Step 4: Build the network</h3><p>A server needs somewhere to live. I built the network layer by layer.</p><p><strong>The VPC</strong>, your own isolated network:</p><pre>resource &quot;aws_vpc&quot; &quot;site&quot; {<br>  cidr_block           = &quot;10.0.0.0/16&quot;<br>  enable_dns_hostnames = true<br><br>  tags = {<br>    Name = &quot;static-site-vpc&quot;<br>  }<br>}</pre><p><strong>The public subnet</strong>, a slice of the VPC in one Availability Zone:</p><pre>resource &quot;aws_subnet&quot; &quot;public&quot; {<br>  vpc_id                  = aws_vpc.site.id<br>  cidr_block              = &quot;10.0.1.0/24&quot;<br>  availability_zone       = &quot;us-east-1a&quot;<br>  map_public_ip_on_launch = true<br><br>  tags = {<br>    Name = &quot;static-site-public&quot;<br>  }<br>}</pre><p><strong>The internet gateway</strong>, the VPC’s door to the internet:</p><pre>resource &quot;aws_internet_gateway&quot; &quot;site&quot; {<br>  vpc_id = aws_vpc.site.id<br>}</pre><p><strong>The route table and association</strong>, which actually make the subnet public:</p><pre>resource &quot;aws_route_table&quot; &quot;public&quot; {<br>  vpc_id = aws_vpc.site.id<br><br>  route {<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    gateway_id = aws_internet_gateway.site.id<br>  }<br>}<br><br><br>resource &quot;aws_route_table_association&quot; &quot;public&quot; {<br>  subnet_id      = aws_subnet.public.id<br>  route_table_id = aws_route_table.public.id<br>}</pre><p>The route says “send all non-local traffic to the internet gateway,” and the association attaches that rule to the subnet. A gateway on its own does nothing until a route points to it.</p><h3>Step 5: Add the security group</h3><p>The security group is a stateful firewall. I only opened what the website needs, which is port 80:</p><pre>resource &quot;aws_security_group&quot; &quot;site&quot; {<br>  name        = &quot;ozort-static-site-sg&quot;<br>  description = &quot;HTTP from anywhere&quot;<br>  vpc_id      = aws_vpc.site.id<br><br>ingress {<br>    from_port   = 80<br>    to_port     = 80<br>    protocol    = &quot;tcp&quot;<br>    cidr_blocks = [&quot;0.0.0.0/0&quot;]<br>  }<br><br>egress {<br>    from_port   = 0<br>    to_port     = 0<br>    protocol    = &quot;-1&quot;<br>    cidr_blocks = [&quot;0.0.0.0/0&quot;]<br>  }<br>}</pre><p>Ingress allows web traffic in. Egress allows the server to reach out, which it needs to download Nginx. There’s no port 22, which follows the principle of least privilege.</p><h3>Step 6: Write the bootstrap script</h3><p>This is the configuration layer. Terraform builds the server, and this script sets up what runs inside it. Put it in user_data.sh:</p><pre>#!/bin/bash<br>set -euxo pipefail<br><br>dnf install -y nginx<br><br>cat &gt; /usr/share/nginx/html/index.html &lt;&lt;&#39;HTML&#39;<br>&lt;h1&gt;Deployed with Terraform&lt;/h1&gt;<br>&lt;p&gt;Built by Bob (Ozort)&lt;/p&gt;<br>HTML<br><br>systemctl enable --now nginx</pre><p>Two details matter:</p><ul><li>#!/bin/bash must be the very first line, because that&#39;s how cloud-init recognises a script.</li><li>Save the file with <strong>LF</strong> line endings, not Windows CRLF. In VS Code, click CRLF in the bottom-right corner and switch it to LF.</li></ul><p>systemctl enable --now nginx starts Nginx immediately and again after every reboot.</p><h3>Step 7: Create the EC2 instance</h3><pre>resource &quot;aws_instance&quot; &quot;site&quot; {<br>  ami                    = data.aws_ssm_parameter.al2023.value<br>  instance_type          = &quot;t3.micro&quot;<br>  subnet_id              = aws_subnet.public.id<br>  vpc_security_group_ids = [aws_security_group.site.id]<br><br>  user_data                   = file(&quot;${path.module}/user_data.sh&quot;)<br>  user_data_replace_on_change = true<br><br>  tags = {<br>    Name = &quot;static-site&quot;<br>  }<br>}</pre><p>user_data only runs on the instance&#39;s first boot. user_data_replace_on_change = true tells Terraform to replace the instance whenever the script changes, so the server always matches the code. That&#39;s the immutable infrastructure pattern: replace rather than patch.</p><h3>Step 8: Attach an Elastic IP</h3><p>Without one, the instance’s public IP changes every time it stops and starts:</p><pre>resource &quot;aws_eip&quot; &quot;site&quot; {<br>  instance   = aws_instance.site.id<br>  domain     = &quot;vpc&quot;<br>  depends_on = [aws_internet_gateway.site]<br>}</pre><p>depends_on makes sure the internet gateway exists before the address is attached.</p><h3>Step 9: Output the URL</h3><p>In outputs.tf:</p><pre>output &quot;site_url&quot; {<br>  value = &quot;http://${aws_eip.site.public_ip}&quot;<br>}</pre><h3>Step 10: Deploy</h3><pre>terraform fmt<br>terraform init<br>terraform validate<br>terraform plan<br>terraform apply</pre><ul><li>init downloads the AWS provider.</li><li>validate checks your syntax.</li><li>plan shows what Terraform is about to create, so you can review it before anything changes.</li><li>apply builds it. Type yes when prompted.</li></ul><p>Terraform reads the references between resources and works out the build order itself. It creates the VPC first, then the subnet, then the instance, then the Elastic IP. You never specify that order.</p><h3>Step 11: Verify</h3><pre>terraform output site_url<br>curl.exe http://&lt;your-elastic-ip&gt;</pre><p>Give the instance a couple of minutes after it shows running, because the bootstrap script needs time to install Nginx. Then open the URL in your browser (use http://, not https://) and the page loads.</p><h3>Step 12: Clean up</h3><pre>terraform destroy</pre><p>Everything Terraform created is removed. Since the whole project is code, you can rebuild it any time with one command.</p><h3>How it all comes together</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/754/1*05CMNYSz7bhMtplg5ELNzw.png" /></figure><h3>What this project taught me</h3><ul><li><strong>Provisioning and configuration are separate concerns.</strong> Terraform builds the infrastructure, and the bootstrap script configures the software.</li><li><strong>A subnet is only public if it has a route to an internet gateway.</strong> The gateway alone isn’t enough.</li><li><strong>Terraform builds a dependency graph.</strong> References between resources decide the order, not the order of the code.</li><li><strong>user_data runs once.</strong> Changing the script means replacing the instance.</li><li><strong>Least privilege starts at the firewall.</strong> Open only the ports the service needs.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2ba2edc0d32e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[OAI vs OAC in CloudFront: What Actually Changed, and Why OAC Wins]]></title>
            <link>https://medium.com/@ozort/oai-vs-oac-in-cloudfront-what-actually-changed-and-why-oac-wins-7217047efd56?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/7217047efd56</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[access-control]]></category>
            <category><![CDATA[cloudfront]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[origin-access-identity]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Thu, 01 Oct 2026 08:07:39 GMT</pubDate>
            <atom:updated>2026-10-01T08:07:39.391Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qLLgYENLV1mfGyEZX6JnKA.png" /></figure><p>If you’ve spent any time reading CloudFront + S3 tutorials, you’ve probably run into both terms Origin Access Identity (OAI) and Origin Access Control (OAC) often without a clear explanation of why there are two mechanisms doing what sounds like the same job. This article breaks down the actual difference, why AWS pushed one out in favor of the other, and what the switch looks like in practice.</p><h4>The problem both of them solve</h4><p>If you’re serving a static site (or any content) from S3 through CloudFront, the naive approach is to make the S3 bucket public. That works, but it means anyone who finds your bucket’s direct URL can bypass CloudFront entirely skipping your caching, your HTTPS enforcement, any WAF rules, all of it. The bucket should stay private, and only CloudFront should be able to read from it.</p><p>OAI and OAC are AWS’s two answers to that same problem: “let CloudFront read this private bucket, and nothing else.”</p><h4>Origin Access Identity (OAI) — the original approach</h4><p>OAI works by creating a special CloudFront “identity” essentially a virtual user that you then attach to your distribution and grant read access to in your bucket policy. Your policy’s Principal ends up pointing at this identity&#39;s canonical user ID rather than at CloudFront directly.</p><p>It works, and for years it was the only option. But it has real limitations:</p><ul><li>It didn’t support S3 server-side encryption with KMS (SSE-KMS)</li><li>It didn’t support S3 Object Lambda</li><li>It didn’t support all S3 regions, including newer opt-in regions launched after a certain point</li><li>It didn’t support dynamic requests to S3 like PUT and DELETE only reads</li><li>It relies on an older authentication mechanism rather than AWS’s standard signing approach</li></ul><p>That last point matters more than it sounds. OAI doesn’t cryptographically sign each request it identifies itself via a fixed identity, which is a weaker security model than what most other AWS services use.</p><h4>Origin Access Control (OAC) — the replacement</h4><p>OAC is the modern replacement for OAI, and it uses AWS Signature Version 4 (SigV4) for authentication, the same standard signing method used across AWS services. Every request CloudFront makes to your bucket is individually signed, rather than relying on a static identity.</p><p>AWS explicitly recommends OAC because it supports all Amazon S3 buckets in all AWS regions (including opt-in regions launched after December 2022), S3 server-side encryption with KMS, and dynamic requests like PUT and DELETE to S3 none of which OAI handles, or which it only handles with workarounds.</p><p>This isn’t a marginal improvement it’s AWS closing every gap OAI had, plus adding proper request signing.</p><h4>Is OAI actually deprecated?</h4><p>Yes, AWS’s own language has shifted from “OAC is recommended” to calling OAI deprecated outright. AWS’s guidance now states plainly that origin access identity has been deprecated in favor of origin access control, and that distributions using OAI should be migrated to OAC to benefit from enhanced security controls. Security posture tools like Datadog’s cloud security rules flag OAI usage specifically for this reason, recommending migration to OAC for the same reasons: enhanced security features including signed requests, granular permissions, and support for IAM policies, plus broader compatibility across S3 and custom origins.</p><p>If you’re starting a new project today, there’s no real argument for choosing OAI over OAC. It’s not a stylistic preference one is actively being phased out.</p><h4>What OAC costs you: a little extra complexity</h4><p>OAC isn’t strictly “easier” than OAI: it asks for more precision, in exchange for closing the gaps above. The tradeoffs summarize cleanly:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/737/1*KOJaiS9-ZGOLGdV8auFZWQ.png" /></figure><p>The two things worth calling out on the OAC side, since they trip people up during migration:</p><ol><li>When using OAC with an S3 origin, your bucket’s Object Ownership setting must be “Bucket owner enforced” (the default for new buckets) if you need ACLs for some reason, you’d use “Bucket owner preferred” instead.</li><li>If your S3 bucket is configured as a website endpoint (S3’s own static website hosting feature) rather than a regular bucket, you can’t use OAC or OAI, for that matter since a website-endpoint origin has to be added to CloudFront as a custom origin instead. This is actually a good argument for using a plain S3 bucket behind CloudFront + OAC rather than S3’s built-in website hosting mode, if you’re setting this up fresh.</li></ol><h4>What the switch actually looks like in Terraform</h4><p>Concretely, moving from OAI to OAC touches two resources.</p><p><strong>Before (OAI):</strong></p><p>hcl</p><pre>resource &quot;aws_cloudfront_origin_access_identity&quot; &quot;oai&quot; {}<br><br># in the distribution&#39;s origin block:<br>s3_origin_config {<br>  origin_access_identity = aws_cloudfront_origin_access_identity.oai.cloudfront_access_identity_path<br>}<br><br># in the bucket policy:<br>Principal = {<br>  AWS = aws_cloudfront_origin_access_identity.oai.iam_arn<br>}</pre><p><strong>After (OAC):</strong></p><p>hcl</p><pre>resource &quot;aws_cloudfront_origin_access_control&quot; &quot;oac&quot; {<br>  name                              = &quot;my-site-oac&quot;<br>  origin_access_control_origin_type = &quot;s3&quot;<br>  signing_behavior                  = &quot;always&quot;<br>  signing_protocol                  = &quot;sigv4&quot;<br>}<br><br># in the distribution&#39;s origin block:<br>origin_access_control_id = aws_cloudfront_origin_access_control.oac.id<br><br># in the bucket policy:<br>Principal = { Service = &quot;cloudfront.amazonaws.com&quot; }<br>Condition = {<br>  StringEquals = {<br>    &quot;AWS:SourceArn&quot; = aws_cloudfront_distribution.this.arn<br>  }<br>}</pre><p>The meaningful shift in the bucket policy: instead of trusting a specific <em>identity</em> (the OAI), you’re trusting the CloudFront <em>service</em> generally, then scoping that trust down to one exact distribution ARN via the Condition block. That Condition is what stops any other CloudFront distribution (yours or anyone else&#39;s) from being able to use this same trust relationship.</p><p>If you’re migrating an already-live distribution rather than building fresh, don’t skip terraform plan here because the origin&#39;s configuration shape changes and the bucket policy&#39;s principal changes entirely, this is the kind of change worth reading through in full rather than applying on faith.</p><h4>So which one should you actually use?</h4><p>OAC, without much debate, for anything new. It closes every functional gap OAI had, uses a stronger authentication model, works with more origin types, and is where AWS is explicitly steering everyone. The only reason you’d still see OAI in a codebase is legacy — something built before OAC existed, or copied from a tutorial that hasn’t been updated. If you’re maintaining one of those, migrating is a well-documented, low-risk change, and AWS’s own security tooling will likely flag it for you if it hasn’t already.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7217047efd56" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Build Your Own S3 + CloudFront Static Site with Terraform: A Step-by-Step Guide]]></title>
            <link>https://medium.com/@ozort/build-your-own-s3-cloudfront-static-site-with-terraform-a-step-by-step-guide-4c7b2f2c36f1?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/4c7b2f2c36f1</guid>
            <category><![CDATA[aws-s3]]></category>
            <category><![CDATA[aws-cloudfront]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[aws]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Wed, 30 Sep 2026 08:52:37 GMT</pubDate>
            <atom:updated>2026-09-30T08:54:27.106Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YlL-9bI8bZhVeuRA7ppv7A.png" /></figure><p>If you’ve never used Terraform to deploy anything on AWS before, this is a good first real project small enough to finish in one sitting, but it touches S3, CloudFront, IAM policies, and remote state, which are things you’ll use in almost everything else you build later.</p><p>We’re going to build a static site from scratch: a private S3 bucket serving content through CloudFront over HTTPS, with a working 404 page.</p><h3>What you’ll need before starting</h3><ul><li>An AWS account, with credentials configured locally. If you haven’t done this yet, run aws configure and enter your access key, secret key, and default region.</li><li><a href="https://developer.hashicorp.com/terraform/downloads">Terraform</a> installed (version 1.x).</li><li>The AWS CLI installed (you’ll use it for one manual step near the end).</li><li>Basic comfort in a terminal. You don’t need to know HCL (Terraform’s language) yet we’ll explain each block as it comes up.</li></ul><h3>Step 1 — Set up your project folder</h3><p>Create a folder and a website/ subfolder for your actual site content:</p><pre>mkdir static-site-project &amp;&amp; cd static-site-project<br>mkdir website</pre><p>Drop a basic website/index.html and website/error.html in there for now ; even a one-line placeholder is fine, we&#39;ll come back to real content later. What matters right now is that the folder exists, because Terraform will read from it.</p><h3>Step 2 — Set up remote state (before you write any Terraform)</h3><p>This is the part beginners usually skip, and it bites them later. By default, Terraform stores its “state” (a record of what it created) in a local file on your machine. That’s fine solo, but it breaks the moment you work from a second machine or with anyone else and it’s also just safer to have state somewhere durable.</p><p>Create an S3 bucket and a DynamoDB table for this, once, outside of Terraform:</p><pre>aws s3api create-bucket --bucket your-terraform-state-bucket --region us-east-1<br>aws dynamodb create-table \<br>  --table-name terraform-locks \<br>  --attribute-definitions AttributeName=LockID,AttributeType=S \<br>  --key-schema AttributeName=LockID,KeyType=HASH \<br>  --billing-mode PAY_PER_REQUEST</pre><p>Replace your-terraform-state-bucket with something unique to you S3 bucket names are global across every AWS account, so a generic name will already be taken.</p><p>Why can’t Terraform create this bucket and table itself? Because the backend has to exist <em>before</em> Terraform can talk to it. it’s a chicken-and-egg problem, so this one step happens outside your Terraform config.</p><h3>Step 3 — Write provider.tf</h3><p>This file tells Terraform which cloud provider to use and where to store its state.</p><pre>terraform {<br>  required_providers {<br>    aws = {<br>      source  = &quot;hashicorp/aws&quot;<br>      version = &quot;~&gt; 6.0&quot;<br>    }<br>  }</pre><pre>  backend &quot;s3&quot; {<br>    bucket         = &quot;your-terraform-state-bucket&quot;<br>    key            = &quot;static-site/terraform.tfstate&quot;<br>    region         = &quot;us-east-1&quot;<br>    dynamodb_table = &quot;terraform-locks&quot;<br>    encrypt        = true<br>  }<br>}</pre><pre>provider &quot;aws&quot; {<br>  region = var.aws_region<br>}</pre><p>Swap in the bucket and table names from Step 2. Note that this block <em>can’t</em> use a variable for the region backend blocks are evaluated before Terraform has loaded your variables, so it has to be a literal string here.</p><h3>Step 4 — Write variables.tf</h3><p>Variables are Terraform’s way of letting you parameterize your config instead of hardcoding values.</p><pre>variable &quot;bucket_name&quot; {<br>  description = &quot;Name of the S3 bucket to host the website. Must be globally unique.&quot;<br>  type        = string<br>}</pre><pre>variable &quot;website_index_document&quot; {<br>  description = &quot;The default page CloudFront serves at the site root&quot;<br>  type        = string<br>  default     = &quot;index.html&quot;<br>}</pre><pre>variable &quot;aws_region&quot; {<br>  description = &quot;AWS region to deploy into&quot;<br>  type        = string<br>  default     = &quot;us-east-1&quot;<br>}</pre><p>Notice bucket_name has no default you&#39;re required to supply it yourself every time you run Terraform. This is deliberate: since bucket names must be globally unique, a default value would just mean &quot;works for me, fails for you.&quot;</p><h3>Step 5 — Start main.tf: the S3 bucket</h3><p>This is where the real infrastructure gets defined. Start with just the bucket:</p><pre>resource &quot;aws_s3_bucket&quot; &quot;website_bucket&quot; {<br>  bucket = var.bucket_name<br>}</pre><p>That’s it for now. one resource, referencing the variable you just defined.</p><h3>Step 6 — Lock the bucket down from public access</h3><p>Even though we’re about to make this bucket serve a public website, the bucket itself should never be directly public only reachable through CloudFront. Add:</p><pre>resource &quot;aws_s3_bucket_public_access_block&quot; &quot;website_bucket&quot; {<br>  bucket = aws_s3_bucket.website_bucket.id</pre><pre>  block_public_acls   = true<br>  ignore_public_acls  = true<br>  block_public_policy     = false<br>  restrict_public_buckets = false<br>}</pre><p>The last two are false on purpose. We&#39;re about to attach a bucket policy that grants access to CloudFront specifically if those were true, that policy would be blocked too.</p><h3>Step 7 — Upload your website files</h3><p>Rather than writing one resource per file (which means editing main.tf every time you add a page), loop over everything in your website/ folder:</p><pre>resource &quot;aws_s3_object&quot; &quot;website_files&quot; {<br>  for_each = fileset(&quot;website/&quot;, &quot;**&quot;)</pre><pre>  bucket = aws_s3_bucket.website_bucket.id<br>  key    = each.value<br>  source = &quot;website/${each.value}&quot;<br>  etag   = filemd5(&quot;website/${each.value}&quot;)</pre><pre>  content_type = lookup(<br>    {<br>      html = &quot;text/html&quot;<br>      css  = &quot;text/css&quot;<br>      js   = &quot;application/javascript&quot;<br>      png  = &quot;image/png&quot;<br>      jpg  = &quot;image/jpeg&quot;<br>      svg  = &quot;image/svg+xml&quot;<br>    },<br>    lower(element(split(&quot;.&quot;, each.value), length(split(&quot;.&quot;, each.value)) - 1)),<br>    &quot;application/octet-stream&quot;<br>  )<br>}</pre><p>A few things worth understanding here, since this block does a lot:</p><ul><li>fileset(&quot;website/&quot;, &quot;**&quot;) lists every file in that folder, recursively.</li><li>for_each creates one aws_s3_object per file found add a new file, and next apply picks it up automatically.</li><li>etag = filemd5(...) is how Terraform knows a file changed if the file&#39;s content hash is different, Terraform re-uploads it.</li><li>The content_type lookup sets the correct MIME type based on file extension. Without this, S3 defaults to a generic binary type, and your browser may try to download your HTML instead of rendering it.</li></ul><h3>Step 8 — Create Origin Access Control (OAC)</h3><p>This is the mechanism that lets CloudFront read from your private bucket without the bucket being public.</p><pre>resource &quot;aws_cloudfront_origin_access_control&quot; &quot;website_oac&quot; {<br>  name                              = &quot;${var.bucket_name}-oac&quot;<br>  description                       = &quot;OAC for ${var.bucket_name} static website&quot;<br>  origin_access_control_origin_type = &quot;s3&quot;<br>  signing_behavior                  = &quot;always&quot;<br>  signing_protocol                  = &quot;sigv4&quot;<br>}</pre><p>You’ll see older tutorials use “Origin Access Identity” (OAI) instead OAC is the newer, AWS-recommended approach, so that’s what we’re using here.</p><h3>Step 9 — The CloudFront distribution</h3><p>This is the biggest block. it’s what actually serves your site to visitors.</p><pre>resource &quot;aws_cloudfront_distribution&quot; &quot;cloudfront_distribution&quot; {<br>  origin {<br>    domain_name              = aws_s3_bucket.website_bucket.bucket_regional_domain_name<br>    origin_id                = var.bucket_name<br>    origin_access_control_id = aws_cloudfront_origin_access_control.website_oac.id<br>  }</pre><pre>  enabled             = true<br>  is_ipv6_enabled     = true<br>  default_root_object = var.website_index_document</pre><pre>  default_cache_behavior {<br>    allowed_methods  = [&quot;GET&quot;, &quot;HEAD&quot;]<br>    cached_methods   = [&quot;GET&quot;, &quot;HEAD&quot;]<br>    target_origin_id = var.bucket_name</pre><pre>    forwarded_values {<br>      query_string = false<br>      cookies {<br>        forward = &quot;none&quot;<br>      }<br>    }</pre><pre>    min_ttl                = 0<br>    default_ttl             = 3600<br>    max_ttl                  = 86400<br>    compress                = true<br>    viewer_protocol_policy = &quot;redirect-to-https&quot;<br>  }</pre><pre>  custom_error_response {<br>    error_code         = 404<br>    response_code      = 404<br>    response_page_path = &quot;/error.html&quot;<br>  }</pre><pre>  custom_error_response {<br>    error_code         = 403<br>    response_code      = 404<br>    response_page_path = &quot;/error.html&quot;<br>  }</pre><pre>  viewer_certificate {<br>    cloudfront_default_certificate = true<br>  }</pre><pre>  restrictions {<br>    geo_restriction {<br>      restriction_type = &quot;none&quot;<br>    }<br>  }</pre><pre>  tags = {<br>    Name        = &quot;Cloudfront Distribution&quot;<br>    Environment = &quot;Dev&quot;<br>  }<br>}</pre><p>Breaking down the pieces beginners usually ask about:</p><ul><li>origin: Points CloudFront at your S3 bucket, using the OAC from Step 8.</li><li>default_root_object : what to serve when someone visits the bare domain (no path).</li><li>viewer_protocol_policy = &quot;redirect-to-https&quot; : anyone hitting HTTP gets bounced to HTTPS automatically.</li><li>default_ttl = 3600 : CloudFront caches responses for an hour before re-checking S3. This matters later when you update content and don&#39;t see the change immediately.</li><li><strong>Both </strong><strong>custom_error_response blocks matter.</strong> You might expect only a 404 rule is needed for a missing page, but S3 actually returns 403 (not 404) for a missing object when your bucket policy doesn&#39;t grant s3:ListBucket which is the case here. Without the 403 rule too, most broken links would bypass your error page entirely.</li><li>viewer_certificate { cloudfront_default_certificate = true } : uses CloudFront&#39;s shared default certificate. Fine for testing; if you want a custom domain later, this block changes.</li></ul><h3>Step 10 — The bucket policy</h3><p>This is what actually grants CloudFront permission to read your bucket:</p><pre>resource &quot;aws_s3_bucket_policy&quot; &quot;website_bucket_policy&quot; {<br>  bucket = aws_s3_bucket.website_bucket.id</pre><pre>  policy = jsonencode({<br>    Version = &quot;2012-10-17&quot;<br>    Statement = [<br>      {<br>        Sid      = &quot;AllowCloudFrontServicePrincipalReadOnly&quot;<br>        Effect   = &quot;Allow&quot;<br>        Action   = &quot;s3:GetObject&quot;<br>        Resource = &quot;${aws_s3_bucket.website_bucket.arn}/*&quot;<br>        Principal = {<br>          Service = &quot;cloudfront.amazonaws.com&quot;<br>        }<br>        Condition = {<br>          StringEquals = {<br>            &quot;AWS:SourceArn&quot; = aws_cloudfront_distribution.cloudfront_distribution.arn<br>          }<br>        }<br>      }<br>    ]<br>  })<br>}</pre><p>The important part is the Condition block it scopes this permission to <em>this specific distribution</em>, not to any CloudFront distribution anyone might create. Without it, any AWS account&#39;s CloudFront distribution could technically read your bucket, which is not what you want.</p><h3>Step 11 — Outputs</h3><p>Add an outputs.tf so Terraform tells you the useful values after it finishes:</p><pre>output &quot;s3_bucket_name&quot; {<br>  value = aws_s3_bucket.website_bucket.id<br>}</pre><pre>output &quot;cloudfront_distribution_domain_name&quot; {<br>  value = aws_cloudfront_distribution.cloudfront_distribution.domain_name<br>}</pre><pre>output &quot;cloudfront_distribution_id&quot; {<br>  value       = aws_cloudfront_distribution.cloudfront_distribution.id<br>  description = &quot;Useful for manually running aws cloudfront create-invalidation if needed&quot;<br>}</pre><p>You’ll actually use cloudfront_distribution_id later, so don&#39;t skip this file.</p><h3>Step 12 — Deploy it</h3><p>Now that every file is in place:</p><pre>terraform init<br>terraform plan -var=&quot;bucket_name=your-unique-bucket-name&quot;</pre><p>Read the plan output before continuing, it should show everything being <em>created</em>, nothing being destroyed (since this is a first deploy). If it looks right:</p><pre>terraform apply -var=&quot;bucket_name=your-unique-bucket-name&quot;</pre><p>Type yes when prompted. After a minute or two, Terraform prints your three outputs. Copy the cloudfront_distribution_domain_name value and open it in a browser that&#39;s your live site.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jaZE4YQREV8_kvlMzYcSLA.png" /><figcaption>My default index.html deployed</figcaption></figure><p>To access your error.html you’d have to add /error at the end of your cloudfront domain i.e cloudfront_distribution_domain_name/error</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6k73UEgPK-Ig5KyFc_jKKA.png" /><figcaption>My default error.html deployed</figcaption></figure><h3>Step 13 — Updating your site later</h3><p>Edit or add a file in website/, then run apply again with the same bucket_name variable. Terraform re-uploads only what changed.</p><p>One thing that surprises beginners: your change might not show up immediately, because CloudFront is still serving a cached response (remember default_ttl = 3600, one hour). You have two options:</p><ul><li><strong>Wait it out:</strong> after an hour, the cache expires on its own.</li><li><strong>Force it now</strong>, using the distribution ID from your outputs:</li></ul><pre>terraform output cloudfront_distribution_id<br>aws cloudfront create-invalidation --distribution-id &lt;the-id-you-just-got&gt; --paths &quot;/*&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/782/1*8J_uQgtNAcesZmRvU2jtkw.png" /><figcaption>Forcing Cache Reset</figcaption></figure><h3>Step 14 — Tearing it down</h3><p>When you’re done experimenting:</p><pre>terraform destroy -var=&quot;bucket_name=your-unique-bucket-name&quot;</pre><p>This removes everything Terraform created the bucket, its contents, the distribution, the policy, and the OAC. Your remote state bucket and DynamoDB table from Step 2 are untouched, since those live outside this config.</p><h3>What you just learned, in one paragraph</h3><p>You built a private S3 bucket that’s only reachable through a specific CloudFront distribution (not the public internet), using Origin Access Control rather than making the bucket itself public which is the pattern you’ll want for basically every static site you host on AWS going forward. You also touched remote state, variable-driven configuration, dynamic resource loops with for_each, and CloudFront&#39;s caching and error-handling behavior all of which come up constantly in real infrastructure work, not just this one project.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4c7b2f2c36f1" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Built a “Simple” Static Site on AWS with Terraform. Here’s Everything That Wasn’t Simple.]]></title>
            <link>https://medium.com/@ozort/i-built-a-simple-static-site-on-aws-with-terraform-heres-everything-that-wasn-t-simple-b5df0948a2a0?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/b5df0948a2a0</guid>
            <category><![CDATA[https]]></category>
            <category><![CDATA[aws-cloudfront]]></category>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[s3]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Wed, 30 Sep 2026 07:42:11 GMT</pubDate>
            <atom:updated>2026-09-30T07:42:11.333Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5pzrlVhW-XW0YCsMVjRxGw.png" /></figure><p>A few weeks ago I set out to do something that should have taken an afternoon: host a static personal site on S3, put CloudFront in front of it for HTTPS, and call it done. Terraform makes this look trivial in every tutorial a bucket, a distribution, a couple of aws_s3_object blocks, apply, ship it.</p><p>It mostly <em>is</em> that simple. But the gap between “it plans clean” and “it’s actually production-sane” is where the real learning happened, and that gap is what I want to walk through.</p><h4>The starting point</h4><p>The first version worked. S3 bucket, CloudFront distribution, an Origin Access Identity (OAI) so the bucket stayed private, a bucket policy scoped to that OAI. Two files uploaded as individual aws_s3_object resources: index.html and error.html. It deployed, the site loaded, green checkmark, move on.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/304/1*tziHRI81mdcE0CgLS4xprQ.png" /><figcaption>Initial Repo Structure</figcaption></figure><p>Except a few things were quietly wrong, and none of them would have shown up until they mattered.</p><h4>OAI is legacy and the fix isn’t a one-line swap</h4><p>AWS has been steering people toward Origin Access Control (OAC) instead of OAI for a while now. OAC signs requests to S3 with SigV4 instead of relying on a canonical-user identity, and it’s the direction CloudFront is actually going. Swapping this in sounds cosmetic. It isn’t.</p><p>The origin block goes from:</p><p>hcl</p><pre>s3_origin_config {<br>  origin_access_identity = aws_cloudfront_origin_access_identity.oai.cloudfront_access_identity_path<br>}</pre><p>to:</p><p>hcl</p><pre>origin_access_control_id = aws_cloudfront_origin_access_control.website_oac.id</pre><p>But the bucket policy’s Principal has to change too from the OAI&#39;s canonical user to the CloudFront <em>service</em> principal, scoped down with a Condition on AWS:SourceArn so it only trusts <em>your</em> distribution:</p><p>hcl</p><pre>Principal = { Service = &quot;cloudfront.amazonaws.com&quot; }<br>Condition = {<br>  StringEquals = {<br>    &quot;AWS:SourceArn&quot; = aws_cloudfront_distribution.cloudfront_distribution.arn<br>  }<br>}</pre><p>Run terraform plan after this change and don&#39;t skim it swapping the origin type forces a replace on the bucket policy and can force distribution changes too, depending on your provider version. If this were already live for real traffic, that&#39;s the kind of plan you read line by line before typing yes.</p><h4>A hardcoded bucket name will bite the next person (or future you)</h4><p>The original variables.tf had a default like bucket_name = &quot;****-bucket-505&quot;. Fine for me, useless for anyone else S3 bucket names are globally unique across <em>every</em> AWS account on the planet, not just yours. If you&#39;re publishing infrastructure code for others to actually run (which, if you&#39;re writing it up on Medium, you probably are), drop the default entirely:</p><p>hcl</p><pre>variable &quot;bucket_name&quot; {<br>  description = &quot;Name of the S3 bucket to host the website. Must be globally unique.&quot;<br>  type        = string<br>}</pre><p>No default means Terraform <em>makes</em> the next person choose their own name instead of silently colliding with mine.</p><h4>Uploading files one resource at a time doesn’t scale</h4><p>The original config had two separate aws_s3_object resources one for index.html, one for error.html. Add a third file to the site and you&#39;re editing main.tf again. That&#39;s not a workflow, that&#39;s a chore.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/365/1*2c485KFSmohoi3SQ8ar1vg.png" /><figcaption>Object Resource</figcaption></figure><p>The fix is a for_each over the actual directory:</p><p>hcl</p><pre>resource &quot;aws_s3_object&quot; &quot;website_files&quot; {<br>  for_each = fileset(&quot;website/&quot;, &quot;**&quot;)<br><br>bucket = aws_s3_bucket.website_bucket.id<br>  key    = each.value<br>  source = &quot;website/${each.value}&quot;<br>  etag   = filemd5(&quot;website/${each.value}&quot;)<br><br>content_type = lookup(<br>    { html = &quot;text/html&quot;, css = &quot;text/css&quot;, js = &quot;application/javascript&quot; },<br>    lower(element(split(&quot;.&quot;, each.value), length(split(&quot;.&quot;, each.value)) - 1)),<br>    &quot;application/octet-stream&quot;<br>  )<br>}</pre><p>Drop a file into website/, run apply, done. The content_type lookup matters too S3 doesn&#39;t guess MIME types for you, and a .html file served as application/octet-stream means your browser downloads it instead of rendering it.</p><h4>The local-exec provisioner that broke on Windows</h4><p>I wanted cache invalidation to happen automatically a null_resource that hashed the website/ directory and ran aws cloudfront create-invalidation whenever content changed. Clean idea. It broke immediately:</p><pre>Error running command &#39;aws cloudfront create-invalidation --distribution-id ... --paths &quot;/*&quot;&#39;: exit status 254.<br>Error: An error occurred (InvalidArgument) ... invalid invalidation paths.</pre><p>The root cause had nothing to do with CloudFront or the AWS CLI itself. local-exec runs through your OS&#39;s default shell cmd.exe on Windows and cmd.exe doesn&#39;t strip embedded double quotes the way bash does. The AWS CLI was literally receiving the four-character string &quot;/*&quot;, quote marks included, instead of /*. CloudFront correctly rejected garbage input.</p><p>The real lesson here isn’t “use PowerShell as your interpreter” (you can, and it fixes it). It’s that <strong>provisioners that shell out are inherently host-dependent</strong>, and that dependency is invisible until someone runs your code on a different OS than you tested on. I ended up ripping it out entirely and documenting the manual invalidation command in the README instead one less moving part, one less thing that behaves differently depending on who’s running it.</p><h4>403, not 404 S3’s error codes will surprise you</h4><p>I added a custom_error_response block so a missing page would route to a friendly error.html instead of raw XML:</p><p>hcl</p><pre>custom_error_response {<br>  error_code         = 404<br>  response_code      = 404<br>  response_page_path = &quot;/error.html&quot;<br>}</pre><p>This alone doesn’t work reliably. When your bucket policy doesn’t grant s3:ListBucket (mine doesn&#39;t no reason to), S3 returns <strong>403 Forbidden</strong> for a missing object, not 404. CloudFront only maps the error code you tell it to map. Without a matching 403 rule, most real &quot;page not found&quot; cases sail straight past your nice error page:</p><p>hcl</p><pre>custom_error_response {<br>  error_code         = 403<br>  response_code      = 404<br>  response_page_path = &quot;/error.html&quot;<br>}</pre><p>Small detail, easy to miss, and the kind of thing you only discover by actually testing a broken link instead of trusting the happy path.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/305/1*OoMV3dbReSksiYc6vdAvrg.png" /><figcaption>Final Repo Structure</figcaption></figure><h4>The dumbest bug of the whole project</h4><p>After all that infrastructure work, my actual error page displayed literal backtick fences (```html) right there on the live site, embedded in the rendered HTML. Not a Terraform problem, not an AWS problem. I&#39;d copy-pasted the file with Markdown code fence syntax still wrapped around it, and it went straight into the repo as-is. The takeaway: infrastructure can be flawless and you&#39;ll still ship a typo straight to production if nothing&#39;s checking your actual content.</p><p>And even after fixing the file and redeploying, the site kept showing the <em>old</em> broken version for a while because CloudFront was still serving a cached copy from before the fix, and I hadn’t invalidated it. A perfectly correct deploy can still look broken to a visitor for up to your cache’s TTL if you don’t force the refresh.</p><h4>What I’d tell someone starting this from scratch</h4><ul><li>Default to OAC over OAI for anything new : OAI still works but you’re building on a path AWS is walking away from.</li><li>Never ship a default value for anything that needs global uniqueness.</li><li>Loop over your content directory instead of hand-writing a resource per file it’s not extra credit, it’s the difference between a config you can actually maintain and one you’ll dread touching in six months.</li><li>Be suspicious of any provisioner that shells out it works on your machine is not a deployment strategy.</li><li>Test your actual 404 path, not just the happy path. S3 and CloudFront’s error codes don’t always match your intuition.</li><li>Cache is a liar. If a fix doesn’t seem to be working, check whether you’re actually looking at the fix or a cached copy of the old version.</li></ul><p>None of these are exotic lessons. They’re the ordinary friction of shipping something for real instead of for a tutorial which, honestly, is where most of the actual learning in DevOps work happens.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b5df0948a2a0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How I Provisioned an AWS EKS Cluster Using Modular Terraform: A Journey from Flat Files to Clean…]]></title>
            <link>https://medium.com/@ozort/how-i-provisioned-an-aws-eks-cluster-using-modular-terraform-a-journey-from-flat-files-to-clean-563d89287e85?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/563d89287e85</guid>
            <category><![CDATA[aws-eks]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[aws]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Fri, 10 Jul 2026 07:59:36 GMT</pubDate>
            <atom:updated>2026-07-10T08:15:09.475Z</atom:updated>
            <content:encoded><![CDATA[<h3>How I Provisioned an AWS EKS Cluster Using Modular Terraform: A Journey from Flat Files to Clean Infrastructure</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nCJVI2CWI0DgNKltJ9T4XQ.png" /></figure><p><em>A honest walkthrough of how I structured, broke, fixed, and shipped a modular Terraform EKS project from scratch</em></p><p>I had been working with Terraform for a while, but everything was going into one big main.tf. It worked until it didn&#39;t. Resources were tangled together, debugging was a nightmare, and the moment I wanted to reuse anything I was copying and pasting blocks of code. I decided to rebuild my EKS project properly, breaking it into modules and doing things the right way.</p><p>This article is a walkthrough of that journey — the architecture decisions, the code I wrote, the errors I hit, and what I learned. I’m not going to tell you to clone anything. The goal is for you to understand the approach well enough to build your own.</p><h3>Why Modules?</h3><p>Before anything else, I want to explain the shift in thinking that made this project click.</p><p>In Terraform, a <strong>module</strong> is just a folder with .tf files. The root module is your top-level directory. Child modules are folders you call from root. Think of them like functions you define the logic once, pass in inputs, and get outputs back. No resource duplication, no spaghetti references.</p><p>The project ended up with this structure:</p><pre>root/<br>├── main.tf              # Wires all modules together<br>├── variables.tf         # Root-level variable declarations<br>├── outputs.tf           # Exposes key values after apply<br>├── terraform.tfvars     # Your real values (gitignored)<br>├── terraform.tfvars.example  # Dummy values (committed)<br>└── modules/<br>    ├── state/           # S3 bucket + DynamoDB for remote state<br>    ├── vpc/             # VPC, subnets, IGW, route tables, SG<br>    ├── iam/             # IAM roles, EKS cluster, node group<br>    └── ec2/             # Bastion EC2 instance</pre><p>Four modules, each owning a distinct layer. The state module lays the foundation. The VPC module builds the network. The IAM module provisions the EKS cluster and its roles. The EC2 module drops a bastion instance into the network.</p><h3>The Golden Rule That Took Me a While to Internalize</h3><p><strong>Modules cannot see each other’s resources directly.</strong></p><p>If the IAM module needs subnet IDs that were created by the VPC module, it cannot reference aws_subnet.my_tf_subnet_1.id directly. Those subnets don&#39;t exist inside the IAM module, they live in the VPC module. The only way to pass values between modules is:</p><ol><li>The VPC module <strong>outputs</strong> the value</li><li>The root main.tf <strong>catches</strong> that output</li><li>The root passes it as an <strong>input variable</strong> to the IAM module</li></ol><p>I hit this as an error probably five or six times before it fully sank in. Every A managed resource has not been declared in module.X error I got during this project came down to this one rule.</p><h3>Step 1 — The State Module</h3><p>The very first thing I built was the state infrastructure. Before any other AWS resources exist, you need somewhere to store the Terraform state file safely.</p><pre>resource &quot;aws_s3_bucket&quot; &quot;terraform_state&quot; {<br>  bucket = &quot;ozort-terraform-state-file&quot;<br>}</pre><pre>resource &quot;aws_s3_bucket_versioning&quot; &quot;enabled&quot; {<br>  bucket = aws_s3_bucket.terraform_state.id<br>  versioning_configuration {<br>    status = &quot;Enabled&quot;<br>  }<br>}</pre><pre>resource &quot;aws_s3_bucket_server_side_encryption_configuration&quot; &quot;default&quot; {<br>  bucket = aws_s3_bucket.terraform_state.id<br>  rule {<br>    apply_server_side_encryption_by_default {<br>      sse_algorithm = &quot;AES256&quot;<br>    }<br>  }<br>}</pre><pre>resource &quot;aws_s3_bucket_public_access_block&quot; &quot;public_access&quot; {<br>  bucket                  = aws_s3_bucket.terraform_state.id<br>  block_public_acls       = true<br>  block_public_policy     = true<br>  ignore_public_acls      = true<br>  restrict_public_buckets = true<br>}</pre><pre>resource &quot;aws_dynamodb_table&quot; &quot;terraform_locks&quot; {<br>  name         = &quot;terraform-up-and-running-locks&quot;<br>  billing_mode = &quot;PAY_PER_REQUEST&quot;<br>  hash_key     = &quot;LockID&quot;</pre><pre>  attribute {<br>    name = &quot;LockID&quot;<br>    type = &quot;S&quot;<br>  }<br>}</pre><p>The S3 bucket stores the state file with versioning enabled so if something goes wrong you can roll back to a previous state. AES256 encryption and public access blocking are non-negotiable. The DynamoDB table handles state locking, preventing two simultaneous terraform apply runs from corrupting the state.</p><p><strong>The chicken-and-egg problem:</strong> You can’t configure Terraform to use an S3 backend before the S3 bucket exists. So the correct order is — apply the state module first to create the bucket and table, then add the backend configuration and run terraform init again to migrate state into S3:</p><pre>terraform {<br>  backend &quot;s3&quot; {<br>    bucket         = &quot;ozort-terraform-state-file&quot;<br>    key            = &quot;terraform.tfstate&quot;<br>    region         = &quot;us-east-1&quot;<br>    dynamodb_table = &quot;terraform-up-and-running-locks&quot;<br>    encrypt        = true<br>  }<br>}</pre><h3>Step 2 — The VPC Module</h3><p>The VPC module owns all the networking. One VPC, two public subnets across two availability zones, an Internet Gateway, a route table, and a security group.</p><pre>resource &quot;aws_vpc&quot; &quot;my_tf_vpc&quot; {<br>  cidr_block = var.vpc-cidr<br>}</pre><pre>resource &quot;aws_subnet&quot; &quot;my_tf_subnet_1&quot; {<br>  vpc_id                  = aws_vpc.my_tf_vpc.id<br>  cidr_block              = var.subnet1-cidr<br>  map_public_ip_on_launch = true<br>  availability_zone       = var.subnet1-az</pre><pre>  tags = {<br>    Name = &quot;my_tf_subnet_1&quot;<br>  }<br>}</pre><pre>resource &quot;aws_subnet&quot; &quot;my_tf_subnet_2&quot; {<br>  vpc_id                  = aws_vpc.my_tf_vpc.id<br>  cidr_block              = var.subnet2-cidr<br>  map_public_ip_on_launch = true<br>  availability_zone       = var.subnet2-az</pre><pre>  tags = {<br>    Name = &quot;my_tf_subnet_2&quot;<br>  }<br>}</pre><pre>resource &quot;aws_internet_gateway&quot; &quot;my_tf_igw&quot; {<br>  vpc_id = aws_vpc.my_tf_vpc.id<br>}</pre><pre>resource &quot;aws_route_table&quot; &quot;my_tf_rt&quot; {<br>  vpc_id = aws_vpc.my_tf_vpc.id</pre><pre>  route {<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    gateway_id = aws_internet_gateway.my_tf_igw.id<br>  }<br>}</pre><pre>resource &quot;aws_route_table_association&quot; &quot;my_tf_rt_association_1&quot; {<br>  subnet_id      = aws_subnet.my_tf_subnet_1.id<br>  route_table_id = aws_route_table.my_tf_rt.id<br>}</pre><pre>resource &quot;aws_route_table_association&quot; &quot;my_tf_rt_association_2&quot; {<br>  subnet_id      = aws_subnet.my_tf_subnet_2.id<br>  route_table_id = aws_route_table.my_tf_rt.id<br>}</pre><p>Two subnets across two AZs is not optional for EKS the control plane requires multi-AZ placement. I learned that the hard way.</p><p>The security group uses the newer aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resource types rather than inline rules, which gives you more granular control:</p><pre>resource &quot;aws_security_group&quot; &quot;my_tf_vpc_sg&quot; {<br>  name   = &quot;my_tf_vpc_sg&quot;<br>  vpc_id = aws_vpc.my_tf_vpc.id<br>}</pre><pre>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;allow_ssh_ipv4&quot; {<br>  security_group_id = aws_security_group.my_tf_vpc_sg.id<br>  cidr_ipv4         = &quot;0.0.0.0/0&quot;<br>  from_port         = 22<br>  ip_protocol       = &quot;tcp&quot;<br>  to_port           = 22<br>}</pre><pre>resource &quot;aws_vpc_security_group_egress_rule&quot; &quot;allow_all_traffic_ipv4&quot; {<br>  security_group_id = aws_security_group.my_tf_vpc_sg.id<br>  cidr_ipv4         = &quot;0.0.0.0/0&quot;<br>  ip_protocol       = &quot;-1&quot;<br>}</pre><p>Note: 0.0.0.0/0 on SSH is fine for a learning project but should be locked down to your specific IP in production.</p><p>The VPC module exposes four outputs that both the IAM and EC2 modules depend on:</p><pre>output &quot;vpc_id&quot; {<br>  value = aws_vpc.my_tf_vpc.id<br>}</pre><pre>output &quot;subnet_1_id&quot; {<br>  value = aws_subnet.my_tf_subnet_1.id<br>}</pre><pre>output &quot;subnet_2_id&quot; {<br>  value = aws_subnet.my_tf_subnet_2.id<br>}</pre><pre>output &quot;sg_id&quot; {<br>  value = aws_security_group.my_tf_vpc_sg.id<br>}</pre><h3>Step 3 — The IAM Module</h3><p>This is the heaviest module. It creates two IAM roles, one for the EKS control plane (ed-eks-master) and one for worker nodes (ed-eks-worker) — attaches all required policies, then provisions the EKS cluster and managed node group.</p><h3>The master role</h3><pre>resource &quot;aws_iam_role&quot; &quot;master&quot; {<br>  name = &quot;ed-eks-master&quot;</pre><pre>  assume_role_policy = &lt;&lt;POLICY<br>{<br>  &quot;Version&quot;: &quot;2012-10-17&quot;,<br>  &quot;Statement&quot;: [<br>    {<br>      &quot;Effect&quot;: &quot;Allow&quot;,<br>      &quot;Principal&quot;: { &quot;Service&quot;: &quot;eks.amazonaws.com&quot; },<br>      &quot;Action&quot;: &quot;sts:AssumeRole&quot;<br>    }<br>  ]<br>}<br>POLICY<br>}</pre><pre>resource &quot;aws_iam_role_policy_attachment&quot; &quot;AmazonEKSClusterPolicy&quot; {<br>  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonEKSClusterPolicy&quot;<br>  role       = aws_iam_role.master.name<br>}</pre><pre>resource &quot;aws_iam_role_policy_attachment&quot; &quot;AmazonEKSVPCResourceController&quot; {<br>  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonEKSVPCResourceController&quot;<br>  role       = aws_iam_role.master.name<br>}</pre><h3>The worker role</h3><p>The worker role is what EC2 nodes use to communicate with the EKS control plane, pull images from ECR, and access other AWS services. I also created a custom autoscaler policy so the cluster can scale its own node group:</p><pre>resource &quot;aws_iam_policy&quot; &quot;autoscaler&quot; {<br>  name   = &quot;ed-eks-autoscaler-policy&quot;<br>  policy = &lt;&lt;EOF<br>{<br>  &quot;Version&quot;: &quot;2012-10-17&quot;,<br>  &quot;Statement&quot;: [<br>    {<br>      &quot;Action&quot;: [<br>        &quot;autoscaling:DescribeAutoScalingGroups&quot;,<br>        &quot;autoscaling:DescribeAutoScalingInstances&quot;,<br>        &quot;autoscaling:SetDesiredCapacity&quot;,<br>        &quot;autoscaling:TerminateInstanceInAutoScalingGroup&quot;,<br>        &quot;ec2:DescribeLaunchTemplateVersions&quot;<br>      ],<br>      &quot;Effect&quot;: &quot;Allow&quot;,<br>      &quot;Resource&quot;: &quot;*&quot;<br>    }<br>  ]<br>}<br>EOF<br>}</pre><p>Policies attached to the worker role: AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, AmazonEC2ContainerRegistryReadOnly, AmazonSSMManagedInstanceCore, AWSXRayDaemonWriteAccess, AmazonS3ReadOnlyAccess, and the custom autoscaler policy above.</p><h3>The EKS cluster and node group</h3><pre>resource &quot;aws_eks_cluster&quot; &quot;eks&quot; {<br>  name     = &quot;ed-eks-01&quot;<br>  role_arn = aws_iam_role.master.arn</pre><pre>  vpc_config {<br>    subnet_ids = [var.subnet_1_id, var.subnet_2_id]<br>  }</pre><pre>  depends_on = [<br>    aws_iam_role_policy_attachment.AmazonEKSClusterPolicy,<br>    aws_iam_role_policy_attachment.AmazonEKSVPCResourceController,<br>  ]<br>}</pre><pre>resource &quot;aws_eks_node_group&quot; &quot;backend&quot; {<br>  cluster_name    = aws_eks_cluster.eks.name<br>  node_group_name = &quot;dev&quot;<br>  node_role_arn   = aws_iam_role.worker.arn<br>  subnet_ids      = [var.subnet_1_id, var.subnet_2_id]<br>  capacity_type   = &quot;ON_DEMAND&quot;<br>  disk_size       = 20<br>  instance_types  = [&quot;t3.micro&quot;]</pre><pre>  remote_access {<br>    ec2_ssh_key               = var.key<br>    source_security_group_ids = [var.sg_id]<br>  }</pre><pre>  labels = tomap({ env = &quot;dev&quot; })</pre><pre>  scaling_config {<br>    desired_size = 2<br>    max_size     = 3<br>    min_size     = 1<br>  }</pre><pre>  update_config {<br>    max_unavailable = 1<br>  }<br>}</pre><p>Notice subnet_ids = [var.subnet_1_id, var.subnet_2_id] not a direct resource reference. The IAM module has no idea how those subnets were created. It just receives their IDs through variables passed in from root.</p><h3>Step 4 — The EC2 Module</h3><p>A simple bastion instance in subnet 1, with a public IP and SSH access:</p><pre>resource &quot;aws_instance&quot; &quot;my_tf_ec2&quot; {<br>  ami                         = var.ami<br>  instance_type               = var.instance-type<br>  key_name                    = var.key<br>  associate_public_ip_address = true<br>  subnet_id                   = var.subnet_1_id<br>  vpc_security_group_ids      = [var.sg_id]</pre><pre>  tags = {<br>    Name = &quot;my_tf_ec2&quot;<br>  }<br>}</pre><p>Clean and simple. Everything it needs the AMI, key pair, subnet, and security group comes in as variables.</p><h3>Wiring It All Together — Root main.tf</h3><p>The root main.tf is where all four modules get called and their outputs get connected:</p><pre>module &quot;state&quot; {<br>  source = &quot;./modules/state&quot;<br>}</pre><pre>module &quot;vpc&quot; {<br>  source       = &quot;./modules/vpc&quot;<br>  vpc-cidr     = var.vpc-cidr<br>  subnet1-cidr = var.subnet1-cidr<br>  subnet2-cidr = var.subnet2-cidr<br>  subnet1-az   = var.subnet1-az<br>  subnet2-az   = var.subnet2-az<br>}</pre><pre>module &quot;iam&quot; {<br>  source      = &quot;./modules/iam&quot;<br>  subnet_1_id = module.vpc.subnet_1_id<br>  subnet_2_id = module.vpc.subnet_2_id<br>  sg_id       = module.vpc.sg_id<br>  key         = var.key<br>}</pre><pre>module &quot;ec2&quot; {<br>  source        = &quot;./modules/ec2&quot;<br>  ami           = var.ami<br>  instance-type = var.instance-type<br>  key           = var.key<br>  subnet_1_id   = module.vpc.subnet_1_id<br>  sg_id         = module.vpc.sg_id<br>}</pre><p>The root creates no AWS resources directly. It just coordinates calling modules, catching their outputs, and passing them as inputs to other modules. The syntax is always module.module_name.output_name.</p><h3>Variables and tfvars</h3><p>The variables.tf file declares what inputs exist, their types, descriptions, and optional defaults:</p><pre>variable &quot;region&quot; {<br>  type        = string<br>  description = &quot;AWS region to deploy resources&quot;<br>  default     = &quot;us-east-1&quot;<br>}</pre><pre>variable &quot;ami&quot; {<br>  type        = string<br>  description = &quot;AMI ID for the EC2 instance&quot;<br>}</pre><pre>variable &quot;key&quot; {<br>  type        = string<br>  description = &quot;Name of the SSH key pair&quot;<br>}</pre><pre>variable &quot;vpc-cidr&quot; {<br>  type        = string<br>  description = &quot;CIDR block for the VPC&quot;<br>  default     = &quot;10.10.0.0/16&quot;<br>}</pre><p>Variables without a default like ami and key must be supplied, usually through terraform.tfvars. But that file never gets committed to git. Instead, a terraform.tfvars.example with dummy values sits in the repo so anyone building something similar knows what to fill in:</p><pre>ami          = &quot;ami-xxxxxxxxxxxxxxxxx&quot;<br>key          = &quot;your-key-pair-name&quot;<br>vpc-cidr     = &quot;10.0.0.0/16&quot;<br>subnet1-cidr = &quot;10.10.1.0/24&quot;<br>subnet2-cidr = &quot;10.10.2.0/24&quot;<br>subnet1-az   = &quot;us-east-1a&quot;<br>subnet2-az   = &quot;us-east-1b&quot;</pre><p>The .gitignore blocks the real file from ever being committed:</p><pre>*.tfvars<br>*.tfstate<br>*.tfstate.backup<br>.terraform/</pre><h3>Outputs</h3><p>After terraform apply completes, these values get printed:</p><pre>output &quot;endpoint&quot; {<br>  value = module.iam.endpoint<br>}</pre><pre>output &quot;worker_node_sg_id&quot; {<br>  value = module.vpc.sg_id<br>}</pre><pre>output &quot;s3_bucket_arn&quot; {<br>  value       = module.state.s3_bucket_arn<br>  description = &quot;The ARN of the S3 bucket&quot;<br>}</pre><pre>output &quot;dynamodb_table_name&quot; {<br>  value       = module.state.dynamodb_table_name<br>  description = &quot;The name of the DynamoDB table&quot;<br>}</pre><p>Every output goes through its module module.iam.endpoint, module.vpc.sg_id, not aws_eks_cluster.eks.endpoint directly. The root module cannot reach inside a child module&#39;s resources.</p><h3>Mistakes I Made and What They Taught Me</h3><p><strong>Hyphens in variable names.</strong> Terraform does not allow hyphens in variable names, output names, or module argument names. I used subnet-id, sg-id, instance-type throughout the project and hit Unsupported argument errors repeatedly. Underscores everywhere subnet_id, sg_id, instance_type — is the correct convention.</p><p><strong>Reaching across module boundaries.</strong> Every error that read A managed resource has not been declared in module.X was the same mistake: referencing a resource that lives in a different module. The fix every time was the same: output it from the source module, catch it in root, pass it as a variable to the destination module.</p><p><strong>Root outputs referencing resources directly.</strong> My outputs.tf originally had lines like value = aws_eks_cluster.eks.endpoint. That resource doesn&#39;t exist in the root module, it lives in the IAM module. Every root output must go through module.module_name.output_name.</p><p><strong>The import trick.</strong> After refactoring from a flat structure into modules, Terraform lost track of resources it had already created. Rather than destroying and recreating everything, I used terraform import to reconnect existing AWS resources to the new module paths in state:</p><pre>terraform import module.state.aws_s3_bucket.terraform_state ozort-terraform-state-file<br>terraform import module.state.aws_dynamodb_table.terraform_locks terraform-up-and-running-locks</pre><p>This is one of the most useful commands in Terraform and one of the least talked about.</p><h3>What’s Next</h3><p>This project gave me a working modular EKS cluster with remote state and proper module separation. The natural next steps are:</p><ul><li>Add private subnets and a NAT gateway for production-grade networking</li><li>Lock down the SSH security group rule from 0.0.0.0/0 to a specific IP</li><li>Configure a Kubernetes provider to deploy workloads onto the cluster</li><li>Set up a CI/CD pipeline with GitHub Actions to run terraform plan on every pull request</li><li>Add tagging conventions across all resources for cost tracking</li></ul><h3>Final Thoughts</h3><p>The shift from a flat Terraform file to a proper modular structure is not just aesthetic. It forces clean separation of concerns, makes individual layers testable in isolation, and makes the infrastructure readable to anyone who picks it up. The errors along the way the module boundary violations, the hyphen naming issues, the state import were each a lesson that wouldn’t have come from just reading the docs.</p><p>Build it, break it, fix it. That’s how it sticks.</p><p><em>Written by Abu Bobby — DevOps &amp; Cloud Engineering practitioner.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=563d89287e85" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Hosting a Static Website on AWS S3 Using Terraform]]></title>
            <link>https://medium.com/@ozort/hosting-a-static-website-on-aws-s3-using-terraform-f32d41a7305a?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/f32d41a7305a</guid>
            <category><![CDATA[aws-s3]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[cloud-services]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[terraform]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Tue, 28 Apr 2026 01:42:12 GMT</pubDate>
            <atom:updated>2026-04-28T01:42:12.382Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*htaoxiZYBAW7NVinSoLCzA.png" /></figure><p>Infrastructure as Code (IaC) is one of the most valuable skills in modern cloud engineering and one of the best ways to practice it is by building something simple but practical.</p><p>In this guide, I’ll walk through how to <strong>create and host a static website on AWS S3 using Terraform</strong>. This project is perfect for beginners looking to solidify their Terraform knowledge while building something tangible.</p><h3><strong>Why Use Terraform Instead of the AWS Console?</strong></h3><p>You <em>could</em> create an S3 bucket manually via the AWS Console but that approach doesn’t scale well.</p><p>Terraform allows you to:</p><ul><li>Automate infrastructure deployment</li><li>Maintain consistency across environments</li><li>Reduce human error</li><li>Version-control your infrastructure</li></ul><p>In short: <strong>faster, safer, and more reliable deployments</strong>.</p><h3>Prerequisites</h3><p>Before you begin, make sure you have:</p><ul><li>An AWS account</li><li>Terraform CLI installed</li><li>Basic understanding of Terraform concepts (providers, resources, variables)</li></ul><h3>What We’ll Build</h3><p>By the end of this tutorial, you’ll have:</p><ul><li>An S3 bucket provisioned with Terraform</li><li>Static website hosting enabled</li><li>Website files uploaded automatically</li><li>Public access configured</li><li>A live website endpoint</li></ul><h3>Step 1: Set Up Your Project Directory</h3><p>Create a working folder and navigate into it:</p><pre>mkdir terraform-s3-web<br>cd terraform-s3-web</pre><h3>Step 2: Configure the AWS Provider</h3><p>Create a file called provider.tf and define your AWS provider.</p><pre>terraform {<br>  required_providers {<br>    aws = {<br>      source  = &quot;hashicorp/aws&quot;<br>      version = &quot;6.42.0&quot;<br>    }<br>  }<br>}<br><br>provider &quot;aws&quot; {<br>  # Configuration options<br>  region = &quot;Your_Specified_region&quot;<br>}</pre><p>Then initialize Terraform:</p><pre>terraform init</pre><p>This downloads the required provider plugins and prepares your environment.</p><h3>Step 3: Define Variables</h3><p>Create a variables.tf file:</p><pre>variable &quot;bucket_name&quot; {<br>  description = &quot;Unique name for the S3 bucket&quot;<br>  type        = string<br>}</pre><p><strong>Tip:</strong> S3 bucket names must be globally unique.</p><h3>Step 4: Create the S3 Bucket</h3><p>Create a main.tf, define your bucket:</p><pre>resource &quot;aws_s3_bucket&quot; &quot;website_bucket&quot; {<br>  bucket = var.bucket_name<br>}</pre><h3>Step 5: Configure Bucket Ownership</h3><p>Ensure all objects are owned by the bucket owner:</p><pre>resource &quot;aws_s3_bucket_ownership_controls&quot; &quot;ownership&quot; {<br>  bucket = aws_s3_bucket.website_bucket.id<br><br>  rule {<br>    object_ownership = &quot;BucketOwnerPreferred&quot;<br>  }<br>}</pre><h3>Step 6: Enable Public Access</h3><p>By default, S3 blocks public access. We need to override that:</p><pre>resource &quot;aws_s3_bucket_public_access_block&quot; &quot;public_access&quot; {<br>  bucket = aws_s3_bucket.website_bucket.id<br><br>  block_public_acls       = false<br>  block_public_policy     = false<br>  ignore_public_acls      = false<br>  restrict_public_buckets = false<br>}</pre><h3>Step 7: Set Bucket ACL</h3><p>Allow public read access:</p><pre>resource &quot;aws_s3_bucket_acl&quot; &quot;acl&quot; {<br>  depends_on = [aws_s3_bucket_ownership_controls.ownership]<br><br>  bucket = aws_s3_bucket.website_bucket.id<br>  acl    = &quot;public-read&quot;<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/496/1*sAQIqSXts4GEya-xkCeiLQ.png" /><figcaption>S3 Bucket Public Access default Settings</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/496/1*sAQIqSXts4GEya-xkCeiLQ.png" /><figcaption>S3 Bucket Public access settings after</figcaption></figure><h3>Step 8: Upload Website Files</h3><p>Upload your static files (e.g., index.html, error.html, images):</p><pre>resource &quot;aws_s3_object&quot; &quot;index&quot; {<br>  bucket = aws_s3_bucket.website_bucket.id<br>  key    = &quot;index.html&quot;<br>  source = &quot;index.html&quot;<br>  acl    = &quot;public-read&quot;<br>}</pre><p>Repeat this block for each file you want to upload.</p><h3>Step 9: Enable Static Website Hosting</h3><p>Configure your bucket for website hosting:</p><pre>resource &quot;aws_s3_bucket_website_configuration&quot; &quot;website&quot; {<br>  depends_on = [aws_s3_object.index]<br><br>  bucket = aws_s3_bucket.website_bucket.id<br><br>  index_document {<br>    suffix = &quot;index.html&quot;<br>  }<br><br>  error_document {<br>    key = &quot;error.html&quot;<br>  }<br>depends_on = [ aws_s3_bucket_acl.example ]<br>}</pre><p>The depends_on argument makes sure the s3 bucket is created first before the other processes are started.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/446/1*0kGoSAxcqlAvxbEut4P9aw.png" /><figcaption>S3 Bucket Default Static website Hosting</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/760/1*bWqQqOax1RTSi-IKiAbJmg.png" /><figcaption>S3 Bucket Static website Hosting After</figcaption></figure><h3>Step 10: Deploy Your Infrastructure</h3><p>Run:</p><pre>terraform plan<br>terraform apply</pre><p>Terraform will show you what it plans to create before applying the changes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/739/1*--GYI3ZI5uF-S4JUOy3ZSQ.png" /></figure><h3>Step 11: Test Your Website</h3><p>After deployment:</p><ol><li>Go to your AWS S3 Console</li><li>Open your bucket</li><li>Navigate to the <strong>Properties</strong> tab</li><li>Scroll to the <strong>Static Website Hosting</strong> section</li><li>Click the <strong>endpoint URL</strong></li></ol><p>🎉 Your website should now be live!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XhW0e7e43Saf1daHrqvtDg.gif" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f32d41a7305a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Deploying a React App to EC2 Using GitHub Actions]]></title>
            <link>https://blog.devops.dev/deploying-a-react-app-to-ec2-using-github-actions-9d8a28b19263?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/9d8a28b19263</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[aws-ec2]]></category>
            <category><![CDATA[github-actions]]></category>
            <category><![CDATA[react]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Tue, 17 Feb 2026 08:13:22 GMT</pubDate>
            <atom:updated>2026-02-19T06:27:23.850Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MwEFZAu_HFRhmRsyRfukdQ.jpeg" /></figure><p>Deploying frontend apps doesn’t have to be scary or complicated. In this article, we’ll walk through how to deploy a <strong>React app built with Vite</strong> to an <strong>AWS EC2 instance</strong>, using <strong>GitHub Actions</strong> to automate the entire process.</p><p>By the end, every push to main will automatically build and deploy your app to your server. No manual uploads. No stress.</p><h3>What We’ll Build</h3><p>We’ll set up a simple but real-world CI/CD pipeline that does the following:</p><ol><li>Create a React app using Vite</li><li>Push the code to GitHub</li><li>Create an EC2 instance on AWS</li><li>Configure a self-hosted GitHub Actions runner on EC2</li><li>Set up a GitHub Actions workflow to deploy the app</li></ol><h3>Step 1: Create a React App with Vite.</h3><h3>Install Node.js</h3><p>Vite requires Node.js <strong>version 18 or higher</strong>.</p><p>Check if Node.js is installed:</p><pre>node -v<br>npm -v</pre><p>If not installed, download the LTS version from <strong>nodejs.org</strong> and install it.</p><h3>Create a New Vite + React Project</h3><p>Run the command below in your terminal:</p><pre>npm create vite@latest &lt;your-app-name&gt;</pre><p>When prompted:</p><ul><li><strong>Project name</strong> → my-react-app (or any name you like)</li><li><strong>Framework</strong> → React</li><li><strong>Variant</strong> → JavaScript</li></ul><h3>Install Dependencies</h3><p>Move into the project directory and install dependencies:</p><pre>cd my-react-app<br>npm install</pre><h3>Run the App Locally</h3><p>Start the development server:</p><pre>npm run dev</pre><p>Open the displayed URL (usually http://localhost:5173) in your browser.<br> You should see the default React + Vite page 🎉</p><h3>Build for Production</h3><p>When you’re ready to deploy:</p><pre>npm run build</pre><p>This creates a dist/ folder containing optimized static files.<br> These are the files we’ll serve with <strong>nginx</strong> on our EC2 instance.</p><h3>Push the Code to GitHub</h3><p>Initialize Git, commit, and push your code to a GitHub repository:</p><pre>git init<br>git add .<br>git commit -m &quot;Initial commit - React app with Vite&quot;<br>git branch -M main<br>git remote add origin https://github.com/your-username/my-react-app.git<br>git push -u origin main</pre><h3>Step 2: Create an EC2 Instance on AWS</h3><p>Log in to the <strong>Amazon Web Services Management Console</strong> and navigate to <strong>EC2 → Launch Instance</strong>.</p><h3>Use the Following Configuration</h3><ul><li><strong>Name</strong>: react-cicd</li><li><strong>AMI</strong>: Ubuntu</li><li><strong>Instance type</strong>: t3.micro (Free Tier)</li><li><strong>Key pair</strong>: Create a new one and download the .pem file</li><li>Tick both allow HTTP/HTTPS tick boxes like on the image below:</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/396/1*s7AHFCyp0FKURs7TPr6dqA.png" /></figure><ul><li>Launch the instance.</li></ul><h3>SSH into the Server</h3><p>From the EC2 dashboard:</p><ol><li>Select your instance</li><li>Click <strong>Connect → SSH Client</strong></li><li>Follow the instructions provided by AWS</li><li>you’re looking for 2 commands that look like the ones below.</li></ol><blockquote>Remember your terminal should be in the directory where you kept your key file! Never share this file with anyone!</blockquote><p>Make your key file secure:</p><pre>chmod 400 your-key.pem</pre><p>Connect to the instance:</p><pre>ssh -i your-key.pem ubuntu@your-ec2-public-dns</pre><h3>Install Updates and Nginx</h3><p>Once connected:</p><pre>sudo apt-get update<br>sudo apt-get install nginx -y</pre><p>Nginx will serve our React production build.</p><h3>Step 3: Set Up a Self-Hosted GitHub Actions Runner</h3><p>Instead of using GitHub’s hosted runners, we’ll run jobs directly on our EC2 instance.</p><p>In your GitHub repository:</p><ol><li>Go to <strong>Settings → Actions → Runners</strong></li><li>Click <strong>New self-hosted runner</strong></li><li>Select <strong>Linux (x64)</strong></li></ol><p>Follow the commands shown <strong>inside your EC2 SSH session</strong>.<br> When you reach ./run.sh, <strong>do not run it</strong>.</p><p>Instead, install the runner as a service:</p><pre>sudo ./svc.sh install<br>sudo ./svc.sh start</pre><p>Refresh the GitHub page until the runner shows as <strong>Idle</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/952/1*U8xG8zgEtnAJmHL4xHxOqQ.png" /></figure><h3>Step 4: Create the GitHub Actions Workflow</h3><p>In your repository, go to the <strong>Actions</strong> tab, search for a Node workflow and select configure.</p><p>This creates a file at:</p><pre>.github/workflows/node.js.yml</pre><p>Replace its contents with:</p><pre>name: Node.js CI<br><br>on:<br>  push:<br>    branches: [ &quot;main&quot; ]<br><br>jobs:<br>  build:<br><br>    runs-on: self-hosted<br><br>    strategy:<br>      matrix:<br>        node-version: [20.x]<br><br>    steps:<br>    - uses: actions/checkout@v3<br>    - name: Use Node.js ${{ matrix.node-version }}<br>      uses: actions/setup-node@v3<br>      with:<br>        node-version: ${{ matrix.node-version }}<br>        cache: &#39;npm&#39;<br>    - run: npm ci<br>    - run: npm run build --if-present<br>    - name: Clear contents of /var/www/html<br>      run: sudo rm -rf /var/www/html/*<br>    - name: Copy dist folder to /var/www/html<br>      run: sudo cp -r dist/* /var/www/html</pre><p>Commit the workflow file. This will immediately trigger the pipeline.</p><h3>Step 5: View Your Deployed App 🎉</h3><p>Go to your EC2 instance details and copy the <strong>Public IPv4 DNS</strong>.</p><p>Important:<br> Remove the s from https.</p><p>Example:</p><pre>http://ec2-35-177-1xx-238.eu-west-2.compute.amazonaws.com</pre><p>Or simply use the public IP address of your EC2 instance.</p><pre>http://xx.xxx.xx.xx</pre><p>Your React app should now be live and served by <strong>nginx</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*flXydq3QY5dUfyjzG0_QOA.png" /></figure><h3>Final Thoughts</h3><p>You’ve just built a full CI/CD pipeline using:</p><ul><li><strong>GitHub</strong> for version control</li><li><strong>GitHub Actions</strong> for automation</li><li><strong>Amazon EC2</strong> for hosting</li></ul><p>Every push to main now automatically builds and deploys your app just like a real production workflow.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9d8a28b19263" width="1" height="1" alt=""><hr><p><a href="https://blog.devops.dev/deploying-a-react-app-to-ec2-using-github-actions-9d8a28b19263">Deploying a React App to EC2 Using GitHub Actions</a> was originally published in <a href="https://blog.devops.dev">DevOps.dev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Deploying Node.js with CI/CD on AWS EC2 Using a Self‑Hosted GitHub Actions Runner]]></title>
            <link>https://medium.com/@ozort/deploying-node-js-with-ci-cd-on-aws-ec2-using-a-self-hosted-github-actions-runner-fc616c0658f5?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/fc616c0658f5</guid>
            <category><![CDATA[nodejs]]></category>
            <category><![CDATA[github-actions]]></category>
            <category><![CDATA[aws-ec2]]></category>
            <category><![CDATA[ci-cd-pipeline]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Thu, 12 Feb 2026 17:43:36 GMT</pubDate>
            <atom:updated>2026-02-12T17:43:36.449Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3T7whf663p3Zr25OumlVbw.jpeg" /></figure><p>If you’re learning DevOps or cloud engineering, setting up a real CI/CD pipeline is one of the best ways to move from theory to practice. In this article, I’ll walk you through how to deploy a simple Node.js application to an AWS EC2 instance using <strong>GitHub Actions with a self‑hosted runner</strong>.</p><p>By the end of this guide, you’ll have:</p><ul><li>A Node.js app running on an EC2 instance</li><li>Automated tests running on every push</li><li>Automatic deployment triggered by GitHub Actions</li></ul><p>This project is beginner‑friendly but also mirrors real‑world CI/CD workflows.</p><h3>What We’ll Build</h3><p>Here’s what the overall flow looks like:</p><ol><li>Create a simple Node.js application</li><li>Push the code to GitHub</li><li>Create EC2 instance on AWS</li><li>Set up a self‑hosted GitHub Actions runner on EC2</li><li>Configure a CI/CD workflow</li><li>Automatically test and deploy the app on every push</li></ol><h3>Step 1: Create a Simple Node.js Application</h3><p>Start by creating a new project directory and initializing a Node.js app:</p><pre>mkdir cicd-app<br>cd cicd-app<br>npm init -y</pre><p>Next, install the required dependencies:</p><pre>npm install express jest supertest</pre><h3>Project Structure</h3><p>Create the following folders and files:</p><pre>mkdir src<br>mkdir src/public<br>touch src/index.js src/app.js src/app.test.js<br>touch src/public/index.html<br>touch .gitignore</pre><h3>Step 2: Application Code</h3><h3>src/index.js</h3><p>This file starts the server:</p><pre>const app = require(&quot;./app&quot;);<br>const port = process.env.PORT || 3000;<br><br>app.listen(port, () =&gt; console.log(&quot;Server listening on port 3000!&quot;));</pre><h3>src/app.js</h3><p>This defines the Express app and routes:</p><pre>const express = require(&quot;express&quot;);<br>const path = require(&quot;path&quot;);<br>const app = express();<br>app.use(express.static(&quot;public&quot;));<br><br>app.get(&quot;/test&quot;, (_req, res) =&gt; {<br>  res.status(200).send(&quot;Hello world&quot;);<br>});<br><br>app.get(&quot;/&quot;, (req, res) =&gt; {<br>  res.sendFile(path.join(__dirname, &quot;public&quot;, &quot;index.html&quot;));<br>});<br><br>module.exports = app;</pre><h3>src/app.test.js</h3><p>A simple test to validate our API endpoint:</p><pre>const app = require(&quot;./app&quot;);<br>const supertest = require(&quot;supertest&quot;);<br>const request = supertest(app);<br><br>describe(&quot;/test endpoint&quot;, () =&gt; {<br>  it(&quot;should return a response&quot;, async () =&gt; {<br>    const response = await request.get(&quot;/test&quot;);<br>    expect(response.status).toBe(200);<br>    expect(response.text).toBe(&quot;Hello world&quot;);<br>  });<br>});</pre><h3>src/public/index.html</h3><p>A basic html page to confirm deployment:</p><pre>&lt;!DOCTYPE html&gt;<br>&lt;html&gt;<br>  &lt;head&gt;<br>    &lt;title&gt;Demo Application Node Web Server&lt;/title&gt;<br>    &lt;style&gt;<br>      h1 {<br>        text-align: center;<br>        margin-right: 5px;<br>      }<br>      body {<br>        color: #bcbcce;<br>        background-color: #151617;<br>        display: flex;<br>        justify-content: center;<br>        align-items: center;<br>        height: 100vh;<br>        margin: 0;<br>      }<br>    &lt;/style&gt;<br>  &lt;/head&gt;<br>  &lt;body&gt;<br>    &lt;h1&gt;Products Page&lt;/h1&gt;<br>    &lt;p&gt;version 1.0&lt;/p&gt;<br>  &lt;/body&gt;<br>&lt;/html&gt;</pre><h3>Step 3: Configure package.json</h3><p>Update your package.json scripts so we can easily run and test the app:</p><pre>&quot;scripts&quot;: {<br>    &quot;start&quot;: &quot;node src&quot;,<br>    &quot;test&quot;: &quot;jest src/app.test.js&quot;<br>  },</pre><p>You can now:</p><ul><li>Start the app with npm start</li><li>Run tests with npm run test</li></ul><p>Add node_modules to .gitignore:</p><pre>node_modules/</pre><p>Push the project to a new GitHub repository.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_b2BqMlXkUdi-_TOHWL0kQ.png" /></figure><h3>Step 4: Create an EC2 Instance</h3><ol><li>Log in to the AWS Management Console</li><li>Go to <strong>EC2 → Launch Instance</strong></li><li>Use the following configuration:</li></ol><ul><li><strong>Name:</strong> node-cicd-app</li><li><strong>AMI:</strong> Ubuntu</li><li><strong>Instance Type:</strong> t3.micro (Free Tier)</li><li><strong>Key Pair:</strong> Create a new one (save the .pem file)</li></ul><p>4. Launch the instance.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EyqngXQR5EmTIxaR0ztexQ.png" /></figure><h3>Step 5: Configure Security Group</h3><ol><li>Select the newly created instance.</li><li>In the tabs below, click <strong>Security</strong>.</li><li>Then click on the <strong>Security groups</strong> link associated with the instance.</li><li>In the <strong>Inbound rules</strong> tab of the security group, click on <strong>Edit inbound rules.</strong></li><li>Edit the instance’s inbound rules to allow:</li></ol><ul><li><strong>SSH (22)</strong> from your IP</li><li><strong>HTTP (80)</strong> from anywhere</li><li><strong>Custom TCP (3000)</strong> from anywhere</li></ul><p>This allows browser access to the Node.js app.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2dH5GR4sIGA42KWZddfTMA.png" /></figure><h3>Step 6: Create a GitHub Actions Workflow</h3><p>In your GitHub repository:</p><ol><li>Go to <strong>Actions</strong></li><li>Search for a Node.js workflow</li><li>Click <strong>Configure</strong></li></ol><p>Replace the generated YAML file with:</p><pre>name: Node.js CI/CD<br><br>on:<br>  push:<br>    branches: [ &quot;main&quot; ]<br><br>jobs:<br>  build:<br>    runs-on: self-hosted<br>    strategy:<br>      matrix:<br>        node-version: [18.x]<br>        # See supported Node.js release schedule at https://nodejs.org/en/about/releases/<br>    steps:<br>    - uses: actions/checkout@v3<br>    - name: Use Node.js ${{ matrix.node-version }}<br>      uses: actions/setup-node@v3<br>      with:<br>        node-version: ${{ matrix.node-version }}<br>        cache: &#39;npm&#39;<br>    - run: npm ci<br>    - run: npm test<br>    - run: pm2 restart backendserver</pre><p>At this point, the workflow will fail because the self‑hosted runner hasn’t been configured yet.</p><h3>Step 7: Connect to EC2 via SSH</h3><p>From your local terminal:</p><ol><li>Navigate to the directory containing your .pem file</li><li>Set permissions:</li></ol><pre>chmod 400 cicd-key.pem</pre><p>3. Connect to the instance:</p><pre> ssh -i cicd-key.pem ubuntu@&lt;EC2_PUBLIC_IP&gt;</pre><p>To find the exact commands for your EC2 instance:</p><ol><li>Head to AWS Management Console and open the newly created instance. Click on the <strong>Connect</strong> button.</li><li>Navigate to SSH Client. You’d find the first and second command specific to your EC2 instance and you can just copy and paste them in your terminal.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*n5JQQBQdOo6F6cUAuWYBpA.png" /></figure><h3>Step 8: Set Up a Self‑Hosted GitHub Actions Runner</h3><p>In your GitHub repository:</p><ul><li>Go to <strong>Settings → Actions → Runners</strong></li><li>Click <strong>New self-hosted runner</strong></li><li>Select <strong>Linux (x64)</strong></li></ul><p>Follow the displayed commands inside your EC2 SSH session.</p><p>After running the ./run.sh command, a “Connected to GitHub” message confirms success.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/705/1*S_G-2CKWFLm4-I5qdofGew.png" /></figure><p>Once installed, start the runner service:</p><pre>sudo ./svc.sh install<br>sudo ./svc.sh start</pre><h3>Step 9: Install Node.js and PM2 on EC2</h3><p>Update the system and install Node.js:</p><pre>sudo apt update<br>curl -sL https://deb.nodesource.com/setup_lts.x | sudo -E bash -<br>sudo apt-get install -y nodejs</pre><p>Install PM2:</p><pre>sudo npm install -g pm2</pre><h3>Step 10: Start the Application</h3><p>Navigate to the project directory created by the runner:</p><pre>cd ~/actions-runner/_work/cicd-app/cicd-app</pre><p>Start the app with PM2:</p><pre>pm2 start src/index.js --name=backendserver</pre><p>The app now runs in the background and survives SSH disconnects.</p><h3>Step 11: Verify Deployment</h3><p>Open your browser and visit:</p><pre>http://&lt;EC2_PUBLIC_IP&gt;:3000</pre><p>You should see the <strong>Products Page — version 1.0</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XB5DSdGRy2qgpPfFvc3Lxg.png" /></figure><h3>Step 12: Test the CI/CD Pipeline</h3><p>Make a small change to index.html and push it to GitHub.</p><p>Once the push is detected:</p><ul><li>GitHub Actions runs tests</li><li>The app is automatically restarted on EC2</li></ul><p>🎉 You’ve successfully built a CI/CD pipeline with Node.js, GitHub Actions, and AWS EC2.</p><h3>Final Thoughts</h3><p>This project demonstrates a real‑world CI/CD setup using a self‑hosted runner. It’s a great foundation for exploring more advanced topics like load balancing, environment variables, secrets management, and containerization.</p><p>If you’re learning DevOps, this is a solid project to showcase in your portfolio.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fc616c0658f5" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Deploying a Static Website on EC2 Using GitHub Actions (After AWS CI/CD Failed Me)]]></title>
            <link>https://medium.com/@ozort/deploying-a-static-website-on-ec2-using-github-actions-after-aws-ci-cd-failed-me-5c9eafdc1c15?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/5c9eafdc1c15</guid>
            <category><![CDATA[github]]></category>
            <category><![CDATA[github-actions]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[deployment]]></category>
            <category><![CDATA[ci-cd-pipeline]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Tue, 20 Jan 2026 21:21:12 GMT</pubDate>
            <atom:updated>2026-01-20T22:16:36.422Z</atom:updated>
            <content:encoded><![CDATA[<p>I recently worked on a project to deploy a static website on an EC2 instance using CI/CD. My original plan was to use <strong>AWS-native services (CodeBuild and CodeDeploy)</strong>, but I ran into unexpected limitations that forced me to pivot.</p><p>In this article, I’ll walk you through:</p><ul><li>My <strong>initial AWS CI/CD setup</strong></li><li>The <strong>issues that blocked me</strong></li><li>How I <strong>successfully switched to GitHub Actions</strong></li><li>And how you can deploy a static site to EC2 using <strong>Nginx + GitHub</strong> Actions</li></ul><p>This is a real-world walkthrough — mistakes, fixes, and all.</p><h3>Pre-Requisites</h3><p>Before kicking off this project there are some things that are quite important to have:</p><ul><li>A <strong>GitHub repository</strong> containing your static website (index.html)</li><li>An <strong>AWS account</strong> with an IAM user with the necessary permissions to access CodeBuild, CodeDeploy, S3, and EC2</li><li>An <strong>EC2 Ubuntu instance</strong></li><li>Basic familiarity with: AWS services, Linux commands, and GitHub.</li></ul><h3>Initial Approach: AWS CI/CD (CodeBuild &amp; CodeDeploy)</h3><p>My first approach was to use <strong>AWS </strong>CI/CD services to build and deploy my static website. To do this, two configuration files are required in your repository:</p><h3>1️⃣ appspec.yaml (For CodeDeploy)</h3><p>This file defines how <strong>CodeDeploy</strong> installs and runs your application on <strong>EC2</strong>. It contains lifecycle hooks like BeforeInstall and AfterInstall, which are typically used to install and start <strong>Nginx</strong>.<br>Configure with this:</p><pre>version: 0.0<br>os: linux<br>files:<br>  - source: /<br>    destination: /var/www/html<br>hooks:<br>  AfterInstall:<br>    - location: scripts/install_nginx.sh<br>      timeout: 300<br>      runas: root<br>  ApplicationStart:<br>    - location: scripts/start_nginx.sh<br>      timeout: 300<br>      runas: root</pre><h3>2️⃣ buildspec.yml (For CodeBuild)</h3><p>This file defines build instructions for <strong>CodeBuild</strong> and is divided into phases:</p><ul><li><strong>Install</strong> — Install Nginx</li><li><strong>Build</strong> — Move index.html to /var/www/html</li><li><strong>Post-build</strong> — Upload artifacts to an S3 bucket</li></ul><p>Configure with this:</p><pre><br>version: 0.2<br><br>phases:<br>  install:<br>    commands:<br>      - echo Installing NGINX<br>      - sudo apt-get update<br>      - sudo apt-get install nginx -y<br>  build:<br>    commands:<br>      - echo Build started on `date`<br>      - cp index.html /var/www/html/<br>  post_build:<br>    commands:<br>      - echo Configuring NGINX<br><br>artifacts:<br>  files:<br>    - &#39;**/*&#39;</pre><h3>Create additional scripts folder</h3><p>Inside your repo, You’d need additional scripts. These scripts are there to tell the server what to do during deployment.</p><p>create a scripts/folder:</p><p>Then these add two files inside it.<br>scripts/install_nginx.sh</p><pre><br>#!/bin/bash<br>apt update -y<br>apt install nginx -y</pre><p>scripts/start_nginx.sh</p><pre><br>#!/bin/bash<br>systemctl start nginx<br>systemctl enable nginx</pre><p>When You’re done your repo should look like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/715/1*He63vejxmpRc6nyv0-lm1g.png" /></figure><h3>Step1: Create an S3 Bucket for Build Artifacts</h3><p>So, the first step is to create an S3 bucket to store build artifacts:<br>1. Head over to the <strong>AWS S3 console</strong><br>2. Click <strong>Create bucket</strong><br>3. Enter a unique name (e.g. ozort-demo)<br>4. Leave all other settings as default<br>5. Click <strong>Create Bucket.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mLUSecGelR8Z6jRVhml3ng.png" /></figure><h3>Step 2: Configure AWS CodeBuild</h3><p>1. Open the <strong>CodeBuild</strong> service from the AWS Console.<br>2. Click <strong>Create Build Project.</strong></p><p><strong>Project Settings</strong></p><ul><li><strong>Project name</strong>: nginx-build</li><li><strong>Project Type</strong>: Default</li><li><strong>Source provider</strong>: GitHub</li><li><strong>Repository</strong>: Public repository</li><li><strong>Repository URL</strong>: Your GitHub repo link</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xTnWwjE7kQfywYxX0fWHPQ.png" /></figure><p><strong>Environment</strong></p><ul><li><strong>Operating system</strong>: Ubuntu</li><li>Leave other settings as default</li></ul><p><strong>Buildspec</strong></p><ul><li>Choose Use a buildspec file (this will use the buildspec.yml from your repository).</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*s0ERaby__-0trdeBqQd9wA.png" /></figure><p><strong>Artifacts</strong></p><ul><li><strong>Type</strong>: Amazon S3</li><li><strong>Bucket</strong>: Your S3 bucket</li><li><strong>Name:</strong> demo-nginx</li><li><strong>Packaging</strong>: Select Zip as the preferred Artifacts packing method.</li><li>Leave everything else as default</li></ul><p>Click <strong>Create build </strong>project, then Start build (it usually takes around 4–5 minutes). Once done, your project will be successfully built and your artifacts will be stored in the S3 bucket!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FpQqiB1pad2IXFydWM-x6Q.png" /></figure><h3>Setting Up EC2 Instance and IAM Roles for Deployment</h3><p>Once the build artifacts were ready, I moved on to deployment.<br>This required:</p><ul><li>IAM roles</li><li>An EC2 instance</li><li>CodeDeploy agent installation</li></ul><h3>Step 3: Create IAM Roles</h3><p>We’ll need just two roles, one for EC2 and another for CodeDeploy.</p><p><strong>Role 1: IAM Role for EC2</strong><br>1. navigate to the IAM Console and click on <strong>Roles </strong>in the sidebar.<br>2. Click <strong>Create Role</strong><br>3. Choose AWS Service, and select <strong>EC2 </strong>under the use case.<br>4. Click <strong>Next</strong>, and attach the following policies:</p><ul><li>AmazonEC2FullAccess</li><li>AmazonEC2RoleforAWSCodeDeploy</li><li>AmazonEC2RoleforAWSCodeDeployLimited</li><li>AWSCodeDeployRole</li><li>AmazonS3FullAccess</li><li>AWSCodeDeployFullAccess</li></ul><p>5. Click Next, Name the role: <strong>ec2-code-deploy</strong>, and Create Role.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BHeJdX64yxenaA9mZxoo0A.png" /></figure><p><strong>Role 2: IAM Role for CodeDeploy (Issue Encountered)</strong></p><p>Due to a recent AWS update, I could no longer select CodeDeploy directly as a use case on a free AWS account.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_0JUplDxwqgRwRcOlz7-hQ.png" /></figure><p><strong>Workaround:</strong><br> Select <strong>EC2</strong> as the use case and manually attach:</p><ul><li>AWSCodeDeployRole</li><li>AmazonS3FullAccess</li><li>AmazonEC2FullAccess</li></ul><p>6. Name the role: <strong>ec2-code-deploy-role</strong> and create it.</p><p>This worked, but it foreshadowed the bigger issue ahead.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_5yZHbKqzNoT68y0-Ph2jw.png" /></figure><h3>Step 4: Launch EC2 and Install CodeDeploy Agent</h3><p>Now we need to launch and setup our EC2 Instance that will serve our static website.<br>1. Navigate to the <strong>EC2 Console</strong>, click Launch Instance<br>2. <strong>Instance name:</strong> nginx-demo</p><p><strong>EC2 Configuration</strong></p><ul><li><strong>AMI</strong>: Ubuntu (latest)</li><li><strong>Instance type:</strong> t3.micro</li><li><strong>Security group</strong>: Allow <strong>HTTP </strong>(port 80) and <strong>HTTPS </strong>access</li><li><strong>Key Pair</strong>: No need to create (we’ll use EC2 Connect)</li><li>Launch the instance</li></ul><p><strong>Install CodeDeploy Agent</strong><br>Once your instanced is running. (You should be able to see this on the EC2 instance page)<br>1. Select the <strong>instance</strong> and click Connect<br>2. Choose <strong>EC2 Instance</strong> Connect and open the <strong>terminal</strong><br>3. Run the following commands:</p><pre><br>sudo apt update`<br>sudo apt install ruby-full -y<br>sudo apt install wget -y<br>cd /home/ubuntu<br>wget https://aws-codedeploy-us-east-1.s3.us-east-1.amazonaws.com/latest/install # Change Region Accordingly<br>chmod +x ./install<br>sudo ./install auto<br>sudo systemctl status codedeploy-agent</pre><blockquote>⚠️ If you’re using a region other than us-east-1 (N. Virginia), change the wget URL to match your region.</blockquote><p>After installation, you should see that the <strong>CodeDeploy</strong> agent is active and running.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*thaPLS0LYSZp8GVvt3L0Dg.png" /></figure><h3>Step 5: Attach IAM Role to EC2 Instance</h3><p>To allow your EC2 instance to access S3 and CodeDeploy, we need to attach the <strong>ec2-code-deploy</strong> role:<br>1. Go back to the <strong>EC2</strong> Console<br>2. Select the nginx-demo instance<br>3. Click <strong>Actions </strong>→ <strong>Security</strong> → Modify <strong>IAM </strong>Role<br>4. Choose <strong>ec2-code-deploy</strong> and click Update <strong>IAM </strong>Role</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FKygv_R1UCRD0bRxqUV0QQ.png" /></figure><h3>The Blocker: CodeDeploy Isn’t Available</h3><p>At this point, I attempted to create a <strong>CodeDeploy</strong> application and discovered that <strong>CodeDeploy </strong>is no longer available on free AWS accounts.<br>This completely stopped the pipeline.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*z2R9HEb8t-h6cjC1bCifFA.jpeg" /></figure><h3>Pivot: Switching to GitHub Actions 🚀</h3><p>After speaking with a mentor, I switched to GitHub Actions, which turned out to be:</p><ul><li>Easier to configure</li><li>Free</li><li>More flexible</li></ul><h3>Switching to GitHub Actions</h3><p>Switching to GitHub Actions will require the following steps<br>· Preparing your <strong>EC2</strong> server<br>· Creating a <strong>deploy </strong>folder on EC2<br>· Adding <strong>SSH Key </strong>to <strong>GitHub</strong> Secrets<br>· Adding <strong>GitHub Actions </strong>to Workflow</p><h3>Step 1: Prepare the EC2 Server</h3><p>Install and start Nginx:</p><pre>sudo apt update<br>sudo apt install nginx -y<br>sudo systemctl enable nginx<br>sudo systemctl start nginx</pre><p>Test in browser:</p><pre>http://your-ec2-Public-ip</pre><blockquote>⚠️ Use <em>http://</em> — not <em>https://</em> — when accessing the EC2 IP.</blockquote><p>You should see Nginx welcome page.</p><p>When I did this test, I got a “<strong>This site can’t be reached</strong>” message and there are a lot of things that could cause this.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*t9rzAkJhK8cmA_9DKfVT8A.png" /></figure><p>Now let’s get into it.<br>First you need to test if your server is connected to the internet.</p><pre><br>Ping google.com<br><br>#Ctrl C to Stop</pre><p>You should see the total number of packets transmitted and received. If its connected it should transmit and receive the same number of times.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/940/1*I6rwtazvw1gVRnNvzmoqRA.png" /></figure><p>Second test would be to check if nginx is active and running</p><pre>Sudo systemctl status nginx</pre><p>It should show active (running) if there are no issues.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qkXYzwheVzdbrdi17_HXHg.png" /></figure><p>Third test would be to load the welcome page from inside the terminal</p><pre><br>curl http://localhost</pre><p>If nothing is wrong you should get the welcome page in raw html form.</p><p>The last test would be the network test because sometimes AWS blocks inbound web traffic by default. It’s the firewall and it’s a common problem.<br>Fix it like this:<br>1. In <strong>EC2 </strong>→ Instances<br>2. Click your <strong>instance</strong><br>3. Scroll down to <strong>Security </strong>tab<br>4. Click the <strong>Security group</strong> link<br>5. Click Edit <strong>inbound rules</strong><br>6. Click <strong>Add rule</strong></p><p>You will be adding 2 rules:</p><ul><li>Allow <strong>HTTP (80)</strong> from 0.0.0.0/0</li><li>Allow <strong>ALL TCP (0</strong>–<strong>65535)</strong> from 0.0.0.0/0</li></ul><p>7. Click Save rule</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YgQC1hW3ydyt3-4n_VvYew.png" /></figure><p>8. Open your <strong>EC2 </strong>Public ip on your web browser and it should show the nginx welcome page.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/553/1*VWkZqDEp8TlunWafuUo_bg.png" /></figure><blockquote>⚠️ Use http:// — not https:// — when accessing the EC2 IP.</blockquote><p>you should have something like this:</p><pre><br>http://52.207.xxx.xx</pre><h3>Step 2: Create Deployment Directory on EC2</h3><p>On your EC2 terminal:</p><pre> sudo mkdir -p /var/www/html<br> sudo chown -R ubuntu:ubuntu /var/www/html</pre><p>Now EC2 is ready to receive files.</p><h3>Step 3: Configure SSH for GitHub Actions</h3><p>Generate SSH keys on EC2:</p><pre> ssh-keygen -t ed25519 -f github-actions-ec2</pre><p>You’ll get:</p><ul><li><em>github-actions-ec2</em>(private key)</li><li><em>github-actions-ec2.pub</em>(public key)</li></ul><p>Add public key to EC2:</p><pre><br>cat github-actions-ec2.pub &gt;&gt; ~/.ssh/authorized_keys</pre><h3>Step 4: Add GitHub Secrets</h3><p>In your GitHub repo:<br><strong>Settings </strong>→<strong> Secrets and variables </strong>→<strong> Actions</strong> → <strong>New repository secret<br></strong>Add:</p><ul><li>EC2_HOST → EC2 public IP</li><li>EC2_USER → ubuntu</li><li>EC2_SSH_KEY → Private SSH key contents</li></ul><blockquote>⚠️ Paste the entire private key including:</blockquote><pre><br> — — -BEGIN OPENSSH PRIVATE KEY — — -<br>…<br> — — -END OPENSSH PRIVATE KEY — — -</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/976/1*6JSl7ewHOlpaFXpydHlngg.png" /></figure><h3>Step 5: Create GitHub Actions Workflow</h3><p>In your repo create this file:</p><pre><br>.github/workflows/deploy.yml</pre><p>Paste this inside:</p><pre><br>name: Deploy Static Site to EC2<br><br>on:<br>  push:<br>    branches:<br>      - main<br><br>jobs:<br>  deploy:<br>    runs-on: ubuntu-latest<br><br>    steps:<br>    - name: Checkout code<br>      uses: actions/checkout@v4<br><br>    - name: Deploy to EC2 via SSH<br>      uses: appleboy/scp-action@v0.1.7<br>      with:<br>        host: ${{ secrets.EC2_HOST }}<br>        username: ${{ secrets.EC2_USER }}<br>        key: ${{ secrets.EC2_SSH_KEY }}<br>        source: &quot;index.html&quot;<br>        target: &quot;/var/www/html/&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/757/1*WI_rSYe3eb_qz1yUz1WLkg.png" /></figure><h3>Final Result 🎉</h3><p>Push to main, and GitHub Actions:</p><ul><li>Runs the workflow</li><li>Deploys your site to EC2</li><li>Replaces the default Nginx page</li></ul><p>Refresh your EC2 IP:</p><pre><br>http://your-ec2-ip</pre><p>Your custom HTML page should now appear instead of Nginx default.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*a7xFAv8AWAc6bp2m9opIDg.png" /></figure><h3>Final Thoughts</h3><p>This project taught me an important lesson:</p><blockquote>Always have a fallback plan in DevOps.</blockquote><p>GitHub Actions turned out to be a simpler and more reliable solution than AWS CodeDeploy for this use case.<br>If this helped you:</p><ul><li>Clap 👏</li><li>Drop a comment</li><li>Or share what broke in your setup.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5c9eafdc1c15" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Sample Sales Data Technical Report.]]></title>
            <link>https://medium.com/@ozort/sample-sales-data-technical-report-c0fd61d6e4d6?source=rss-7e177ee486ef------2</link>
            <guid isPermaLink="false">https://medium.com/p/c0fd61d6e4d6</guid>
            <category><![CDATA[hng]]></category>
            <category><![CDATA[hnginternship]]></category>
            <category><![CDATA[sales-data-analysis]]></category>
            <category><![CDATA[sales-data]]></category>
            <category><![CDATA[data-analysis]]></category>
            <dc:creator><![CDATA[Bobby Abu | DevOps]]></dc:creator>
            <pubDate>Tue, 02 Jul 2024 02:31:22 GMT</pubDate>
            <atom:updated>2024-07-02T02:31:22.287Z</atom:updated>
            <content:encoded><![CDATA[<p>By Bobby Abu</p><p><strong>INTRODUCTION</strong></p><p>The sample sales dataset is a dataset that contains information about the sales of miniature vehicle toys such as motorcycles, Classic cars, planes, trains, vintage cars, trucks and buses over the course of three years across various countries.</p><p>The purpose of this technical report is to identify trends, patterns and anomalies in the given dataset. Identifying these will help further understand the given data and tell its story.</p><p><strong>OBSERVATIONS</strong></p><p>After carefully observing the dataset, certain things could be noted,</p><ol><li>Classic cars seem to be the product line with the highest demand and sales overall. In most of the countries its the most ordered product line and in Switzerland its the only product line that is ordered.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/600/1*gV0cRdqGYFG0LKl91zdZNg.png" /><figcaption>Product line sales Pie chart</figcaption></figure><p>2. I observed that the highest overall orders come from the United States (USA) followed by Spain, France and Australia. These regions prove to be strong marketing regions and would behighly profitable for most miniature toy automotive businesses.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dotyZLQje8TbyPnI9dXwBw.png" /><figcaption>Product line sales for each country</figcaption></figure><p>3. I also noted that there was an increase in the quantity and sales of each product line in the year 2004 compared to the year 2003 and there was a general decline in the quantity ordered and sold in the year 2005 across all product lines.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/600/1*beo4UMoKyorx7HYSQNksQw.png" /><figcaption>Quantity ordered across the years.</figcaption></figure><p><strong>CONCLUSION</strong></p><ol><li>Given the strong performance of the classic cars product line worldwide, it is advisable to increase production, develop innovative designs, and expand marketing efforts into new regions.</li><li>We should intensify marketing efforts in regions with low sales, considering promotions and discounts to encourage customer engagement.</li><li>A more detailed research and analysis should be done to study what caused the increase in orders in the year 2004 and what changed in the year 2005that caused the decline.</li></ol><p><strong>THANK YOU.</strong></p><p><a href="https://hng.tech/internship">https://hng.tech/internship</a>, <a href="https://hng.tech/hire">https://hng.tech/hire</a>,</p><p><a href="https://hng.tech/premium">https://hng.tech/premium</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c0fd61d6e4d6" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>