<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Israel</title>
    <description>The latest articles on DEV Community by Israel (@israeltheory).</description>
    <link>https://dev.to/israeltheory</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F757985%2F1c7b950a-f761-4d95-a70c-613261c87fe0.png</url>
      <title>DEV Community: Israel</title>
      <link>https://dev.to/israeltheory</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/israeltheory"/>
    <language>en</language>
    <item>
      <title>Automating Deployment with Github Actions</title>
      <dc:creator>Israel</dc:creator>
      <pubDate>Sun, 20 Sep 2026 22:49:56 +0000</pubDate>
      <link>https://dev.to/israeltheory/automating-deployment-with-github-actions-24i1</link>
      <guid>https://dev.to/israeltheory/automating-deployment-with-github-actions-24i1</guid>
      <description>&lt;h1&gt;
  
  
  Deploying to a Server You Can't Reach: Building a CI/CD Pipeline with AWS SSM and OIDC
&lt;/h1&gt;

&lt;p&gt;From pushing code manually to building, testing, containerizing, and deploying every change automatically.&lt;/p&gt;

&lt;p&gt;In the previous article, we learned how to spin up a functioning network and compute resources on AWS, and deploy our application on them. Let's remember a key architectural decision: our EC2 instance is in a private subnet, shielded from the internet. &lt;/p&gt;

&lt;p&gt;Now we have a problem. It gets tedious to log in, pull code, and build our Docker images every time we make changes to our codebase. &lt;/p&gt;

&lt;p&gt;Enter CI/CD. &lt;/p&gt;

&lt;p&gt;CI/CD stands for Continuous Integration and Continuous Delivery/Deployment. It aims to streamline and accelerate the software development lifecycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous Integration (CI)&lt;/strong&gt; refers to the practice of automatically integrating code changes into a shared source code repository. &lt;strong&gt;Continuous Delivery and/or Deployment (CD)&lt;/strong&gt; is a two-part process that refers to the integration, testing, and delivery of code changes. Continuous delivery stops short of automatic production deployment, while continuous deployment automatically releases the updates into the production environment. This means our deployment should happen every time we push code to our &lt;a href="https://github.com/Israel-dot-com/apartment-deployment-main" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(If you need a refresher on our setup, check it out here: &lt;a href="https://dev.to/israeltheory/terraform-a-guide-to-deploying-1he8"&gt;Terraform Deployment&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is the catch: our server has no public IP address. It sits in a private subnet behind a load balancer. GitHub Actions cannot SSH into it. There is no port 22 open to the internet. There is no bastion host.&lt;/p&gt;

&lt;p&gt;So how do you deploy to a server you can't reach from the internet?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This is More Complex Than a Typical CI/CD Setup
&lt;/h2&gt;

&lt;p&gt;Typically, we would set up a pipeline that builds a Docker image, pushes it to a registry, then SSHes into a server with a stored SSH key to pull the image and restart the container.&lt;/p&gt;

&lt;p&gt;None of that works for this setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The First Problem: The server is unreachable.&lt;/strong&gt; The EC2 instance lives in a private subnet (&lt;code&gt;10.0.10.0/24&lt;/code&gt;). Traffic from the internet goes through the Application Load Balancer, not directly to the server. There is no public IP. GitHub Actions cannot SSH in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Second Problem: Four containers, not one.&lt;/strong&gt; This is not a single Docker image deployment. It is a &lt;code&gt;docker-compose.yml&lt;/code&gt; stack with four interdependent services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PostgreSQL → (health check passes) → Backend → (health check passes) → Frontend → Nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the backend starts before PostgreSQL is healthy, the Alembic database migrations crash. If Nginx starts before the backend is healthy, it throws a 502 Bad Gateway. Docker Compose manages this dependency chain with health checks and &lt;code&gt;depends_on&lt;/code&gt; conditions. So the deployment tool needs to orchestrate Docker Compose on the server, not just swap one container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem 3: No stored credentials.&lt;/strong&gt; I did not want AWS access keys sitting in GitHub Secrets. Keys do not expire. If they leak, they are valid until someone notices and manually revokes them. That could be weeks. Or months. In a personal project, probably never.&lt;/p&gt;

&lt;p&gt;I needed a solution that was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keyless&lt;/strong&gt; (no stored AWS credentials)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reachable&lt;/strong&gt; (can talk to a private subnet server)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composable&lt;/strong&gt; (can orchestrate a multi-container stack)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answer turned out to be three AWS services I had already partially set up: &lt;strong&gt;IAM OIDC&lt;/strong&gt;, &lt;strong&gt;Systems Manager (SSM)&lt;/strong&gt;, and &lt;strong&gt;Terraform&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Here is how the pipeline works end to end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer pushes to main
         │
         ▼
┌─────────────────────────────┐
│  GitHub Actions CI Workflow  │
│  ┌───────────────────────┐  │
│  │ Docker Compose Build  │  │
│  │ Frontend Build (Vite) │  │
│  │ Backend Lint (Flake8) │  │
│  └───────────────────────┘  │
└──────────────┬──────────────┘
               │ (all checks pass)
               ▼
┌─────────────────────────────┐
│  GitHub Actions CD Workflow  │
│                             │
│  1. Request OIDC token from │
│     GitHub's token service  │
│                             │
│  2. Present token to AWS    │
│     STS: "I am repo X,      │
│     running workflow Y"     │
│                             │
│  3. AWS returns temporary   │
│     credentials (1 hour)    │
│                             │
│  4. Use AWS CLI to send     │
│     SSM command to EC2      │
│                             │
│  5. EC2 executes deploy.sh  │
│     (git pull, compose up)  │
│                             │
│  6. Fetch logs, verify      │
│     success or fail build   │
└─────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No SSH. No stored keys. No open ports. The only thing stored in GitHub Secrets is the ARN of an IAM role — a reference, not a credential.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The CI Pipeline — Catching Problems Before They Reach the Server
&lt;/h2&gt;

&lt;p&gt;The CI workflow runs on every push to &lt;code&gt;main&lt;/code&gt; and every pull request. Its job is simple: make sure the code is not broken before we even think about deploying.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Docker Buildx&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate Docker Compose build&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker compose build&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Node.js for Frontend&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check Frontend build&lt;/span&gt;
      &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./fe-apartment&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;npm install&lt;/span&gt;
        &lt;span class="s"&gt;npm run build&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Python for Backend&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.12'&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lint Backend (Flake8)&lt;/span&gt;
      &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./be-apartment&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;python -m pip install --upgrade pip&lt;/span&gt;
        &lt;span class="s"&gt;pip install flake8&lt;/span&gt;
        &lt;span class="s"&gt;flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics&lt;/span&gt;
        &lt;span class="s"&gt;flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose build validation.&lt;/strong&gt; This builds all four container images. If a Dockerfile is broken, a &lt;code&gt;requirements.txt&lt;/code&gt; has a bad package, or Nginx config has a syntax error — it fails here, not on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend build.&lt;/strong&gt; React with Vite. &lt;code&gt;npm install&lt;/code&gt; then &lt;code&gt;npm run build&lt;/code&gt;. If someone introduces a TypeScript error or a bad import, the build fails. The CI catches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend lint.&lt;/strong&gt; Flake8 on the FastAPI codebase. The first pass (&lt;code&gt;--select=E9,F63,F7,F82&lt;/code&gt;) catches hard errors — syntax errors, undefined names, things that will crash at runtime. The second pass reports style warnings without failing the build. I am strict on things that break. I am lenient on things that are ugly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why run Flake8 in two passes? Because if you block every single style violation from day one on a codebase that was not linted before, you will spend your first week fixing hundreds of trailing whitespace warnings instead of shipping features. The first pass is a wall. The second pass is a nudge.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: No Stored Keys — The OIDC Trust Relationship
&lt;/h2&gt;

&lt;p&gt;This is the part I am most proud of.&lt;/p&gt;

&lt;p&gt;To deploy, the pipeline needs to talk to AWS. The traditional approach: create an IAM user, generate access keys, store them as &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; in GitHub Secrets.&lt;/p&gt;

&lt;p&gt;That works. But those keys never expire. If they leak through a log, a screenshot, or a misconfigured workflow that echoes environment variables, they are valid forever. Or until someone remembers to rotate them. On a personal project? That is never.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OIDC flips the model.&lt;/strong&gt; Instead of storing a key, you set up a trust relationship. GitHub says: &lt;em&gt;"I am the repository &lt;code&gt;Israel-dot-com/apartment-deployment-main&lt;/code&gt;, running a workflow on the &lt;code&gt;main&lt;/code&gt; branch."&lt;/em&gt; AWS checks its trust policy and says: &lt;em&gt;"I trust that repository. Here are temporary credentials that expire in one hour."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;No keys stored anywhere. If the token somehow leaks, it is useless in 60 minutes. And it only works from my specific repository — not from a fork, not from a different org, not from someone who copied my workflow file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting It Up With Terraform
&lt;/h3&gt;

&lt;p&gt;I did not want to click through the AWS Console to set this up. The whole point of this project is Infrastructure as Code. So the OIDC provider and IAM role live in Terraform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/github_oidc.tf&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_openid_connect_provider"&lt;/span&gt; &lt;span class="s2"&gt;"github"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://token.actions.githubusercontent.com"&lt;/span&gt;
  &lt;span class="nx"&gt;client_id_list&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# AWS recommends these official GitHub thumbprints&lt;/span&gt;
  &lt;span class="nx"&gt;thumbprint_list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"6938fd4d98bab03faadb97b34396831e3780aea1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"1c58a3a8518e8759bf075b76b750d4f2df264fcd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"06d927fecd0a84aeba28aad1d808139470fe95c3"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"github_actions"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-github-actions-role"&lt;/span&gt;

  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Federated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;StringLike&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;# Pro-Tip: Add wildcards around your org/repo names! &lt;/span&gt;
          &lt;span class="c1"&gt;# GitHub occasionally appends internal numeric IDs to the claim.&lt;/span&gt;
          &lt;span class="s2"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"repo:Israel-dot-com*/apartment-deployment-main*"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Condition&lt;/code&gt; block is critical. &lt;code&gt;StringLike&lt;/code&gt; on the &lt;code&gt;sub&lt;/code&gt; claim locks this role to my specific repository. No other GitHub repository in the world can assume it. If someone forks my repo and runs the workflow, AWS rejects the token. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;(&lt;/em&gt;&lt;em&gt;Pro Tip:&lt;/em&gt;* Notice the &lt;code&gt;*&lt;/code&gt; after the organization and repository names! GitHub recently started occasionally appending internal numeric IDs to OIDC tokens, so a strict &lt;code&gt;StringEquals&lt;/code&gt; might randomly fail. Using &lt;code&gt;StringLike&lt;/code&gt; with wildcards saves you hours of debugging!)*&lt;/p&gt;

&lt;h3&gt;
  
  
  Least Privilege: Only What the Pipeline Needs
&lt;/h3&gt;

&lt;p&gt;The role gets exactly three permissions. Notice how we split the &lt;code&gt;ssm:SendCommand&lt;/code&gt; resource array to properly accommodate tag conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"github_actions_ssm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ec2:DescribeInstances"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;# Allow running SSM commands on our specific tagged instance&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ssm:SendCommand"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s2"&gt;"ssm:ResourceTag/Name"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-server"&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;# Allow access to the AWS managed shell script document&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ssm:SendCommand"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:ssm:${var.aws_region}::document/AWS-RunShellScript"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;# Allow retrieving the command logs&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ssm:GetCommandInvocation"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. The role cannot create resources. It cannot delete infrastructure. It cannot read secrets. It cannot modify IAM. It can do exactly one thing: tell one specific server to run a shell script, and read the result.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: Deploying to a Server Nobody Can Reach
&lt;/h2&gt;

&lt;p&gt;Here is the problem again: the EC2 instance has no public IP. It lives in a private subnet. GitHub Actions cannot SSH into it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS Systems Manager (SSM)&lt;/strong&gt; solves this. The SSM Agent runs on the EC2 instance and maintains a persistent outbound connection to the AWS SSM service. When you send a command through the AWS API, SSM relays it to the agent on the instance. The server reaches out. Nobody reaches in.&lt;/p&gt;

&lt;p&gt;No open inbound ports. No SSH keys. No bastion hosts. And every command is logged in CloudTrail — who ran what, when, and what happened.&lt;/p&gt;

&lt;p&gt;The EC2 instance already had the &lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; policy attached from when I built the Terraform infrastructure, and the SSM Agent comes pre-installed on Ubuntu AMIs. So this just worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Deploy Script
&lt;/h3&gt;

&lt;p&gt;The script that actually runs on the server is deliberately simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"========================================="&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Starting deployment at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"========================================="&lt;/span&gt;

&lt;span class="nv"&gt;PROJECT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/home/ubuntu/apartment-deployment"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Pull the latest code&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--&amp;gt; Pulling latest code from main branch..."&lt;/span&gt;
git fetch origin main
git checkout main
git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; origin/main

&lt;span class="c"&gt;# Build and start the containers&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--&amp;gt; Rebuilding and starting Docker containers..."&lt;/span&gt;
docker compose build
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Clean up dangling images to free up space&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--&amp;gt; Cleaning up unused Docker images..."&lt;/span&gt;
docker image prune &lt;span class="nt"&gt;-f&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"========================================="&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Deployment completed successfully at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"========================================="&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; is important. If any command fails — &lt;code&gt;git fetch&lt;/code&gt; times out, &lt;code&gt;docker compose build&lt;/code&gt; hits a bad Dockerfile, &lt;code&gt;docker compose up&lt;/code&gt; crashes — the script exits immediately with a non-zero status. SSM reports it as a failure, the GitHub Action fails, and I get notified.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git reset --hard origin/main&lt;/code&gt; instead of &lt;code&gt;git pull&lt;/code&gt; is intentional. The server is not a workspace; it is a deployment target. It should always precisely mirror &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker image prune -f&lt;/code&gt; is the line that saves you at 3am. Every &lt;code&gt;docker compose build&lt;/code&gt; creates new images. On a standard EBS volume, it takes about two weeks of deploys before the disk fills up and Docker refuses to build anything. Pruning after every deploy prevents that.&lt;/p&gt;

&lt;h3&gt;
  
  
  The CD Workflow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CD&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main"&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;    &lt;span class="c1"&gt;# Required for OIDC&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to EC2&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_OIDC_ROLE_ARN }}&lt;/span&gt;
        &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy via AWS Systems Manager&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;INSTANCE_ID=$(aws ec2 describe-instances \&lt;/span&gt;
          &lt;span class="s"&gt;--filters "Name=tag:Name,Values=apartment-server" \&lt;/span&gt;
                    &lt;span class="s"&gt;"Name=instance-state-name,Values=running" \&lt;/span&gt;
          &lt;span class="s"&gt;--query "Reservations[*].Instances[*].InstanceId" \&lt;/span&gt;
          &lt;span class="s"&gt;--output text)&lt;/span&gt;

        &lt;span class="s"&gt;if [ -z "$INSTANCE_ID" ]; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "Error: Could not find apartment-server"&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;
        &lt;span class="s"&gt;fi&lt;/span&gt;

        &lt;span class="s"&gt;# Note: We run the command as the 'ubuntu' user to avoid Git &lt;/span&gt;
        &lt;span class="s"&gt;# "dubious ownership" errors caused by SSM running as root.&lt;/span&gt;
        &lt;span class="s"&gt;COMMAND_ID=$(aws ssm send-command \&lt;/span&gt;
          &lt;span class="s"&gt;--instance-ids "$INSTANCE_ID" \&lt;/span&gt;
          &lt;span class="s"&gt;--document-name "AWS-RunShellScript" \&lt;/span&gt;
          &lt;span class="s"&gt;--parameters 'commands=["sudo -i -u ubuntu bash /home/ubuntu/apartment-deployment/deploy.sh"]' \&lt;/span&gt;
          &lt;span class="s"&gt;--query "Command.CommandId" \&lt;/span&gt;
          &lt;span class="s"&gt;--output text)&lt;/span&gt;

        &lt;span class="s"&gt;echo "Waiting for deployment to complete (timeout: 10 minutes)..."&lt;/span&gt;
        &lt;span class="s"&gt;# We use a custom polling loop here because the default 'aws ssm wait' &lt;/span&gt;
        &lt;span class="s"&gt;# times out after 100 seconds, which isn't long enough for Docker builds!&lt;/span&gt;
        &lt;span class="s"&gt;for i in {1..60}; do&lt;/span&gt;
          &lt;span class="s"&gt;STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" --query "Status" --output text)&lt;/span&gt;
          &lt;span class="s"&gt;if [[ "$STATUS" != "InProgress" &amp;amp;&amp;amp; "$STATUS" != "Pending" &amp;amp;&amp;amp; "$STATUS" != "Delayed" ]]; then&lt;/span&gt;
            &lt;span class="s"&gt;break&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
          &lt;span class="s"&gt;sleep 10&lt;/span&gt;
        &lt;span class="s"&gt;done&lt;/span&gt;

        &lt;span class="s"&gt;echo "Deployment Output:"&lt;/span&gt;
        &lt;span class="s"&gt;aws ssm get-command-invocation \&lt;/span&gt;
          &lt;span class="s"&gt;--command-id "$COMMAND_ID" \&lt;/span&gt;
          &lt;span class="s"&gt;--instance-id "$INSTANCE_ID" \&lt;/span&gt;
          &lt;span class="s"&gt;--query "StandardOutputContent" --output text&lt;/span&gt;

        &lt;span class="s"&gt;if [ "$STATUS" != "Success" ]; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "Error log:"&lt;/span&gt;
          &lt;span class="s"&gt;aws ssm get-command-invocation \&lt;/span&gt;
            &lt;span class="s"&gt;--command-id "$COMMAND_ID" \&lt;/span&gt;
            &lt;span class="s"&gt;--instance-id "$INSTANCE_ID" \&lt;/span&gt;
            &lt;span class="s"&gt;--query "StandardErrorContent" --output text&lt;/span&gt;
          &lt;span class="s"&gt;echo "Deployment failed with status: $STATUS"&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;
        &lt;span class="s"&gt;fi&lt;/span&gt;

        &lt;span class="s"&gt;echo "Deployment completed successfully!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me walk through what happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OIDC authentication.&lt;/strong&gt; &lt;code&gt;aws-actions/configure-aws-credentials@v4&lt;/code&gt; handles the entire OIDC handshake. It requests a token from GitHub, presents it to AWS STS, receives temporary credentials, and configures the AWS CLI. One action. Zero stored keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the server by tag.&lt;/strong&gt; The pipeline does not hardcode an instance ID. If I destroy and recreate the infrastructure with Terraform, the pipeline finds the new instance dynamically using its name tag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send the command.&lt;/strong&gt; &lt;code&gt;ssm:SendCommand&lt;/code&gt; tells the EC2 instance to run &lt;code&gt;deploy.sh&lt;/code&gt;. (Notice we use &lt;code&gt;sudo -i -u ubuntu&lt;/code&gt; to ensure we execute as the correct user).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait and verify.&lt;/strong&gt; Because &lt;code&gt;docker compose build&lt;/code&gt; can take a few minutes, we use a custom bash loop to poll SSM for up to 10 minutes. Once it completes, we fetch the logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The only thing stored in GitHub Secrets is &lt;code&gt;AWS_OIDC_ROLE_ARN&lt;/code&gt; — the ARN of the IAM role. This is not a credential. It is a reference. AWS will reject the OIDC token from any other source.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: What Happens When It Breaks
&lt;/h2&gt;

&lt;p&gt;A green pipeline does not mean your application works. It means your code compiled, your images built, and the deploy script exited with status 0. But Docker Compose reporting healthy containers and your load balancer agreeing are two different things.&lt;/p&gt;

&lt;p&gt;The ALB health check hits &lt;code&gt;/&lt;/code&gt; every 30 seconds. If the Nginx container is up but the backend behind it is crashing, the ALB marks the target as unhealthy. Users get a 502. The pipeline is green. The app is down.&lt;/p&gt;

&lt;p&gt;This is why monitoring exists alongside CI/CD. The pipeline catches code problems. Monitoring catches runtime problems. They are complementary, not interchangeable.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Rollback
&lt;/h3&gt;

&lt;p&gt;When a bad deploy makes it through, the rollback is fast. Identify the bad commit and revert it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git revert &amp;lt;bad-commit-sha&amp;gt; &lt;span class="nt"&gt;--no-edit&lt;/span&gt;
git push origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. &lt;code&gt;git revert&lt;/code&gt; creates a new commit that undoes the bad change. The push triggers the CD pipeline. The pipeline authenticates with OIDC, sends the SSM command, the server pulls the reverted code, rebuilds the containers, and Docker Compose brings everything back up in the correct dependency order.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned Building This
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The server should not be special
&lt;/h3&gt;

&lt;p&gt;Before CI/CD, the server accumulates manual changes. Over time, the server drifts from what the code says it should be. &lt;code&gt;git reset --hard origin/main&lt;/code&gt; enforces the truth: the server is a mirror of the &lt;code&gt;main&lt;/code&gt; branch. Nothing more. If it is not in git, it does not exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Security and convenience are not a trade-off
&lt;/h3&gt;

&lt;p&gt;I expected the OIDC setup to be painful. It was about 50 lines of Terraform and one GitHub Secret. And now I never think about key rotation, key expiration, or key leakage. The credentials do not exist until the pipeline needs them, and they stop existing an hour later.&lt;/p&gt;

&lt;p&gt;SSM was similar. No port 22 open. No SSH keys to distribute. No bastion host to maintain. And I get a full audit trail in CloudTrail for free.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Health check chains are the real deployment logic
&lt;/h3&gt;

&lt;p&gt;My &lt;code&gt;docker-compose.yml&lt;/code&gt; has a strict dependency chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgres (healthy) → backend (healthy) → frontend (started) → nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service waits for the previous one to pass its health check before starting. This means &lt;code&gt;docker compose up -d&lt;/code&gt; is not just "start everything." It is an orchestrated, ordered, health-verified rollout. &lt;/p&gt;

&lt;h3&gt;
  
  
  4. Disk space kills you silently
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;docker compose build&lt;/code&gt; creates new image layers. &lt;code&gt;docker image prune -f&lt;/code&gt; after every deploy is not optional. It is the difference between a pipeline that works for a month and one that works forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Picture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terraform creates:                     GitHub Actions uses:
├── VPC + Private Subnets              ├── OIDC → Temporary AWS credentials
├── ALB (public-facing)                ├── SSM → Deploy to private EC2
├── EC2 (private, no public IP)        ├── CI  → Build + Lint + Validate
├── NAT Gateway (outbound only)        └── CD  → git pull + compose up
├── IAM Role for EC2 (SSM Agent)
├── OIDC Provider (GitHub trust)
└── IAM Role for GitHub (SSM perms)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean separation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next Steps
&lt;/h2&gt;

&lt;p&gt;This pipeline deploys on every push to &lt;code&gt;main&lt;/code&gt;. That is continuous deployment. For a production application with real users, I would add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A staging environment.&lt;/strong&gt; Deploy to a staging EC2 first. Run integration tests against it. Only promote to production after validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual approval gates.&lt;/strong&gt; A &lt;code&gt;github_environment&lt;/code&gt; with required reviewers. The pipeline pauses and waits for a human to click "Approve" before deploying to production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container image registry.&lt;/strong&gt; Push built images to AWS ECR with commit-hash tags. Instead of building on the server, pull pre-built images. Build once, deploy many times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets in Parameter Store.&lt;/strong&gt; Move &lt;code&gt;.env&lt;/code&gt; values into AWS Systems Manager Parameter Store. The deploy script pulls them at runtime instead of relying on manually created files on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is a future article. I'll link them here when they're ready.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full infrastructure code (Terraform), CI/CD workflows, and Docker Compose configuration are all available on &lt;a href="https://github.com/Israel-dot-com/apartment-deployment-main" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If this helped you, drop a like or leave a comment — I'd love to hear how you're solving deployment to private subnets.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>githubactions</category>
      <category>github</category>
    </item>
    <item>
      <title>AWS Ecommerce store</title>
      <dc:creator>Israel</dc:creator>
      <pubDate>Wed, 09 Sep 2026 06:35:41 +0000</pubDate>
      <link>https://dev.to/israeltheory/aws-ecommerce-store-i6o</link>
      <guid>https://dev.to/israeltheory/aws-ecommerce-store-i6o</guid>
      <description>&lt;p&gt;In a bid to understand more about the services AWS offers, it makes sense to finally build an E-Commerce store using just AWS services. What better way to understand how exactly they work and in what scenarios they work best.&lt;/p&gt;

&lt;p&gt;A rough architecture would be something like this, &lt;/p&gt;

</description>
    </item>
    <item>
      <title>Terraform: A Guide to Deploying</title>
      <dc:creator>Israel</dc:creator>
      <pubDate>Sat, 05 Sep 2026 09:55:35 +0000</pubDate>
      <link>https://dev.to/israeltheory/terraform-a-guide-to-deploying-1he8</link>
      <guid>https://dev.to/israeltheory/terraform-a-guide-to-deploying-1he8</guid>
      <description>&lt;p&gt;In the previous article we manually built a production VPC, subnets, security groups, NAT Gateway, Application Load Balancer, and EC2 instance through the AWS Console. We deployed our Dockerized Smash Apartment application and made it accessible to via the ALB's DNS.&lt;/p&gt;

&lt;p&gt;It worked perfectly. But it took a lot of time, was tedious and error prone, and tearing it down involved a tedious 12-step manual process that easily failed if you got the dependency order wrong.&lt;/p&gt;

&lt;p&gt;Enter Terraform, it's the perfect solution to our problem, we can scaffold the entire architecture, plan to see changes to our architecture and destroy it easily.&lt;/p&gt;

&lt;p&gt;In this article, we are going to rebuild the exact same infrastructure from Part 1, but this time we'll use Infrastructure as Code (IaC). The entire architecture will live in a few code files. One command to create it. One command to destroy it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Introduction: Why Terraform?&lt;/li&gt;
&lt;li&gt;Pre-requisites&lt;/li&gt;
&lt;li&gt;
Step 1: Project Structure and Foundation

&lt;ul&gt;
&lt;li&gt;1.1 Provider Configuration (&lt;code&gt;main.tf&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;1.2 Input Variables (&lt;code&gt;variables.tf&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 2: Networking — VPC and Subnets

&lt;ul&gt;
&lt;li&gt;2.1 Defining the Network (&lt;code&gt;vpc.tf&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 3: Security Groups

&lt;ul&gt;
&lt;li&gt;3.1 Firewall Rules (&lt;code&gt;security_groups.tf&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 4: Application Load Balancer

&lt;ul&gt;
&lt;li&gt;4.1 ALB Configuration (&lt;code&gt;alb.tf&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 5: EC2 Instance and Bootstrap

&lt;ul&gt;
&lt;li&gt;5.1 User Data Script (&lt;code&gt;user_data.sh&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;5.2 EC2 Configuration (&lt;code&gt;ec2.tf&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 6: Outputs and Deployment

&lt;ul&gt;
&lt;li&gt;6.1 Outputs Configuration (&lt;code&gt;outputs.tf&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;6.2 Variable Assignment (&lt;code&gt;terraform.tfvars&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;6.3 Deploying the Infrastructure&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Step 7: The Cleanup — One Command&lt;/li&gt;
&lt;li&gt;Conclusion &amp;amp; Next Steps&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Introduction: Why Terraform?
&lt;/h2&gt;

&lt;p&gt;Terraform is an open-source tool that lets you define cloud infrastructure using a declarative configuration language (HCL). Instead of clicking through the AWS Console, you write code that says "I want a VPC with this CIDR block."&lt;/p&gt;

&lt;p&gt;Why does this beat the manual console method?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Repeatable&lt;/strong&gt;: The same configuration produces the exact same infrastructure every time. Need a staging environment? Just copy the folder, change the environment variable, and deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version Controlled&lt;/strong&gt;: Infrastructure changes go through git, pull requests, and code reviews, just like application code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-Documenting&lt;/strong&gt;: The &lt;code&gt;.tf&lt;/code&gt; files act as the ultimate source of truth. No more guessing which security group allows what traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated Dependency Management&lt;/strong&gt;: Terraform knows the NAT Gateway needs the Elastic IP, and it knows the subnets need the VPC. It creates them in the exact right order.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Pre-requisites
&lt;/h2&gt;

&lt;p&gt;Before we begin, ensure you have the following ready:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AWS CLI configured&lt;/strong&gt;: Configured with Administrator access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform installed&lt;/strong&gt;: Download and install Terraform (v1.5+).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EC2 Key Pair&lt;/strong&gt;: A key pair created in AWS (e.g., &lt;code&gt;apartment-key&lt;/code&gt;), same as &lt;a href="https://dev.to/israeltheory/deploying-a-full-stack-production-application-on-aws-how-to-guide-49mh"&gt;Part 1&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Application Code&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   git clone https://github.com/Israel-dot-com/apartment-deployment-main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 1: Project Structure and Foundation
&lt;/h2&gt;

&lt;p&gt;Let's look at the structure of our Terraform configuration. Splitting the configuration by concern makes it much easier to maintain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terraform/
├── main.tf                  # AWS Provider config and default tags
├── variables.tf             # All configurable inputs
├── vpc.tf                   # VPC, subnets, gateways, route tables
├── security_groups.tf       # Security groups for ALB and EC2
├── alb.tf                   # Load balancer, target group, listeners
├── ec2.tf                   # EC2 instance, AMI lookup, IAM role
├── user_data.sh             # Bash script that installs Docker on boot
├── outputs.tf               # Prints the ALB URL and SSH commands after deploy
└── terraform.tfvars.example # Example variable values
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1.1 Provider Configuration (&lt;code&gt;main.tf&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;First, we tell Terraform we are working with AWS and define our default tags.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.tf&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.5"&lt;/span&gt;

  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;aws&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 5.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_region&lt;/span&gt;

  &lt;span class="nx"&gt;default_tags&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;Project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_name&lt;/span&gt;
      &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;
      &lt;span class="nx"&gt;ManagedBy&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why default tags?&lt;/strong&gt; The &lt;code&gt;default_tags&lt;/code&gt; block automatically applies these tags to &lt;em&gt;every&lt;/em&gt; resource Terraform creates. This is incredibly useful for billing and resource tracking.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.2 Input Variables (&lt;code&gt;variables.tf&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Next, we define the inputs. These are the exact same CIDR blocks and values we typed into the console in Part 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# variables.tf (abridged for length)&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"aws_region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS region to deploy into"&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"project_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"apartment"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"vpc_cidr"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"public_subnet_cidrs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.1.0/24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"10.0.2.0/24"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_cidrs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.11.0/24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"10.0.12.0/24"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"availability_zones"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"us-east-1a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1b"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"key_pair_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Name of an existing EC2 key pair for SSH access"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"domain_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Domain name for the ALB HTTPS listener (leave empty to skip HTTPS)"&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Networking — VPC and Subnets
&lt;/h2&gt;

&lt;p&gt;Remember the 10+ manual console steps from Part 1? Creating the VPC, enabling DNS hostnames, creating four subnets, allocating an EIP, creating the NAT Gateway, and setting up route tables? Here is all of that, codified.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Defining the Network (&lt;code&gt;vpc.tf&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# vpc.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_cidr&lt;/span&gt;
  &lt;span class="nx"&gt;enable_dns_support&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;enable_dns_hostnames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-vpc"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_internet_gateway"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-igw"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Creates 2 Public Subnets dynamically using count&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"public"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_subnet_cidrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_subnet_cidrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;availability_zone&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;availability_zones&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;map_public_ip_on_launch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-public-${count.index + 1}"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Creates 2 Private Subnets dynamically using count&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"private"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_cidrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_cidrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;availability_zone&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;availability_zones&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-private-${count.index + 1}"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_eip"&lt;/span&gt; &lt;span class="s2"&gt;"nat"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_nat_gateway"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allocation_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_eip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_internet_gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Notice the &lt;code&gt;depends_on&lt;/code&gt; block in the NAT Gateway.&lt;/strong&gt; This tells Terraform it must wait for the Internet Gateway to exist before creating the NAT Gateway. Terraform maps out a dependency graph for everything automatically, but occasionally explicit dependencies are helpful.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Security Groups
&lt;/h2&gt;

&lt;p&gt;In Part 1, we made sure the EC2 instance only accepted traffic from the ALB. Let's replicate that.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Firewall Rules (&lt;code&gt;security_groups.tf&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# security_groups.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"alb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-sg-alb"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow HTTP/HTTPS inbound to ALB"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_security_group_ingress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"alb_http"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;from_port&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;to_port&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_ipv4&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"ec2"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-sg-ec2"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow traffic from ALB and SSH"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_security_group_ingress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"ec2_from_alb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;from_port&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;to_port&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;referenced_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;referenced_security_group_id = aws_security_group.alb.id&lt;/code&gt;. This is the exact Terraform equivalent of selecting the ALB security group as the source in the AWS Console.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Application Load Balancer
&lt;/h2&gt;

&lt;p&gt;Our ALB configuration includes a powerful feature: conditional HTTPS.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 ALB Configuration (&lt;code&gt;alb.tf&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# alb.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-alb"&lt;/span&gt;
  &lt;span class="nx"&gt;internal&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application"&lt;/span&gt;
  &lt;span class="nx"&gt;security_groups&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;subnets&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public&lt;/span&gt;&lt;span class="p"&gt;[*].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb_target_group"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-tg"&lt;/span&gt;
  &lt;span class="nx"&gt;port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;health_check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt;
    &lt;span class="nx"&gt;matcher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200-399"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb_listener"&lt;/span&gt; &lt;span class="s2"&gt;"http"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;port&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP"&lt;/span&gt;

  &lt;span class="nx"&gt;default_action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"redirect"&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;

    &lt;span class="c1"&gt;# Forward to EC2 if no domain is provided&lt;/span&gt;
    &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
      &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;target_group&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb_target_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Redirect to HTTPS if domain is provided&lt;/span&gt;
    &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"redirect"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
      &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"443"&lt;/span&gt;
        &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTPS"&lt;/span&gt;
        &lt;span class="nx"&gt;status_code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP_301"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you leave &lt;code&gt;domain_name = ""&lt;/code&gt; in your variables, it provisions a standard HTTP load balancer. Once you are ready for production, you just update that variable to &lt;code&gt;"yourdomain.com"&lt;/code&gt;. Run &lt;code&gt;terraform apply&lt;/code&gt; again, and Terraform will dynamically generate an ACM certificate and update the ALB to redirect HTTP to HTTPS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: EC2 Instance and Bootstrap
&lt;/h2&gt;

&lt;p&gt;In Part 1, we manually SSH'd into the instance to install Docker and Git. Let's automate that using an EC2 &lt;code&gt;user_data&lt;/code&gt; script.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 User Data Script (&lt;code&gt;user_data.sh&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;This script runs automatically the very first time the EC2 instance boots up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/log/user-data.log 2&amp;gt;&amp;amp;1

apt-get update &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# Install Docker&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker ubuntu

&lt;span class="c"&gt;# Install Git&lt;/span&gt;
apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; git

&lt;span class="c"&gt;# Clone application repository&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; ubuntu git clone &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;app_repo_url&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /home/ubuntu/apartment-deployment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5.2 EC2 Configuration (&lt;code&gt;ec2.tf&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ec2.tf&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_ami"&lt;/span&gt; &lt;span class="s2"&gt;"ubuntu"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;most_recent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;owners&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"099720109477"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# Canonical&lt;/span&gt;
  &lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt;
    &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_instance"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ami&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_ami&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ubuntu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;instance_type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance_type&lt;/span&gt;
  &lt;span class="nx"&gt;key_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key_pair_name&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_security_group_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;user_data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;templatefile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${path.module}/user_data.sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;app_repo_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_repo_url&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-server"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Instance Connect Endpoint for secure, bastion-less SSH&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ec2_instance_connect_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;data "aws_ami"&lt;/code&gt; block dynamically fetches the latest Ubuntu 24.04 AMI ID. You no longer have to hardcode AMI IDs that break when you switch AWS regions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Outputs and Deployment
&lt;/h2&gt;

&lt;h3&gt;
  
  
  6.1 Outputs Configuration (&lt;code&gt;outputs.tf&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Outputs print useful information to your terminal after the deployment finishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# outputs.tf&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"alb_dns_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DNS name of the Application Load Balancer"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dns_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"ssh_command"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Command to SSH into the EC2 instance"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws ec2-instance-connect ssh --instance-id ${aws_instance.app.id} --os-user ubuntu"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6.2 Variable Assignment (&lt;code&gt;terraform.tfvars&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Copy the example file to &lt;code&gt;.tfvars&lt;/code&gt; (which is ignored by Git, keeping your secrets safe):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;terraform.tfvars.example terraform.tfvars
nano terraform.tfvars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update it with your values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;aws_region&lt;/span&gt;    &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="nx"&gt;key_pair_name&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"apartment-key"&lt;/span&gt;
&lt;span class="nx"&gt;app_repo_url&lt;/span&gt;  &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/your-repo/apartment-deployment.git"&lt;/span&gt;
&lt;span class="c1"&gt;# domain_name = "yourdomain.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6.3 Deploying the Infrastructure
&lt;/h3&gt;

&lt;p&gt;Initialize Terraform. This downloads the necessary AWS provider plugins.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run a plan to see exactly what Terraform is going to create.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnwrjtgs2xk08x8tvfyel.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnwrjtgs2xk08x8tvfyel.png" alt="Plan Terraform" width="800" height="634"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You'll see a list of 31 resources that Terraform plans to create (VPC, Subnets, ALB, EC2, etc.).&lt;/p&gt;

&lt;p&gt;Finally, apply the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type &lt;code&gt;yes&lt;/code&gt; when prompted. &lt;/p&gt;

&lt;p&gt;Terraform will create everything in the right order. It automatically waits the 2 minutes required for the NAT Gateway to provision before attaching routes to it.&lt;/p&gt;

&lt;p&gt;When it finishes, it will print your outputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Outputs:

alb_dns_name &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"apartment-alb-xxxxxxxxxx.us-east-1.elb.amazonaws.com"&lt;/span&gt;
ssh_command &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws ec2-instance-connect ssh --instance-id i-0123456789abcdef0 --os-user ubuntu"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy that SSH command, connect to your server, configure your &lt;code&gt;.env&lt;/code&gt; files (just like we did in Part 1), and run &lt;code&gt;docker compose up -d --build&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything we spent an entire article building manually was just created in about 2 minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidmjvbugdivh0lmpnt9j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidmjvbugdivh0lmpnt9j.png" alt="Apply Terraform" width="800" height="725"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: The Cleanup — One Command
&lt;/h2&gt;

&lt;p&gt;In Part 1, tearing down this infrastructure required manually deleting 12 different resources in exactly the right order, waiting for timeouts, and dealing with &lt;code&gt;DependencyViolation&lt;/code&gt; errors.&lt;/p&gt;

&lt;p&gt;Here is the Terraform version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type &lt;code&gt;yes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Terraform looks at its state file, maps the reverse dependencies, and destroys everything flawlessly. It detaches the IGW before deleting the VPC. It waits for the NAT Gateway to delete before releasing the EIP. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9oxw22o9y60hpobedcfw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9oxw22o9y60hpobedcfw.png" alt="Terraform destroy" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion &amp;amp; Next Steps
&lt;/h2&gt;

&lt;p&gt;This setup gets you a very solid production foundation. But cloud architecture never stops evolving. Our next steps for this platform would include:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-Scaling (ASG)&lt;/strong&gt;: Moving from a single EC2 instance to an Auto Scaling Group behind the ALB to handle traffic spikes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI/CD Pipeline&lt;/strong&gt;: Using GitHub Actions to automatically build the Docker images and trigger rolling updates on EC2 via AWS CodeDeploy.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you found this helpful, drop a like! You can find the full source code (including all the Terraform files) on the terraform directory of &lt;a href="https://github.com/Israel-dot-com/apartment-deployment-main" rel="noopener noreferrer"&gt;Github&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>infrastructure</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Deploying a Full-Stack Production Application on AWS How-To-Guide</title>
      <dc:creator>Israel</dc:creator>
      <pubDate>Sat, 05 Sep 2026 09:24:29 +0000</pubDate>
      <link>https://dev.to/israeltheory/deploying-a-full-stack-production-application-on-aws-how-to-guide-49mh</link>
      <guid>https://dev.to/israeltheory/deploying-a-full-stack-production-application-on-aws-how-to-guide-49mh</guid>
      <description>&lt;p&gt;REPO:&lt;a href="https://github.com/Israel-dot-com/apartment-deployment-main" rel="noopener noreferrer"&gt;https://github.com/Israel-dot-com/apartment-deployment-main&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the first article in my DevOps Porfolio series. We'll deploy a Full Stack Application, It currently runs perfectly locally with docker compose up.&lt;/p&gt;

&lt;p&gt;Now we want it on the internet.&lt;/p&gt;

&lt;p&gt;It's a React + FastAPI + PostgreSQL project, to get it up and running via the cloud, We'll need to create a proper isolated network, a load balancer, and security groups &amp;amp; we'll do it all through the AWS Console, it looks tedious and there has to be a better way but we'll persevere through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Introduction&lt;/li&gt;
&lt;li&gt;Pre-requisites&lt;/li&gt;
&lt;li&gt;
Step 1: Networking — Build the VPC Foundation

&lt;ul&gt;
&lt;li&gt;1.1 Create the VPC&lt;/li&gt;
&lt;li&gt;1.2 Create the Subnets&lt;/li&gt;
&lt;li&gt;1.3 Create and Attach the Internet Gateway&lt;/li&gt;
&lt;li&gt;1.4 Create the NAT Gateway&lt;/li&gt;
&lt;li&gt;1.5 Create and Configure Route Tables&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 2: Security Groups

&lt;ul&gt;
&lt;li&gt;2.1 Create ALB Security Group&lt;/li&gt;
&lt;li&gt;2.2 Create EC2 Security Group&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 3: Launch EC2 Instance

&lt;ul&gt;
&lt;li&gt;3.1 Launch the Instance&lt;/li&gt;
&lt;li&gt;3.2 SSH Access via Instance Connect Endpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Step 4: Install Prerequisites on EC2&lt;/li&gt;
&lt;li&gt;
Step 5: Deploy the Application

&lt;ul&gt;
&lt;li&gt;5.1 Clone the Repository&lt;/li&gt;
&lt;li&gt;5.2 Configure Environment Files&lt;/li&gt;
&lt;li&gt;5.3 Build and Start the Stack&lt;/li&gt;
&lt;li&gt;5.4 Debugging and Fixes&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 6: Application Load Balancer

&lt;ul&gt;
&lt;li&gt;6.1 Create Target Group&lt;/li&gt;
&lt;li&gt;6.2 Create the ALB&lt;/li&gt;
&lt;li&gt;6.3 Test the Application&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Step 7: The Cleanup Problem&lt;/li&gt;
&lt;li&gt;Next Steps&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I'll be using a previous unfinished project, a hospitality and car rental marketplace. Vendors list short-let apartments and vehicles, complete identity verification, and manage bookings. Admins moderate listings and track analytics through a dashboard. Users browse, book, and manage support tickets.&lt;/p&gt;

&lt;p&gt;The architecture looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb791mhft7gnzvqacagox.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb791mhft7gnzvqacagox.png" alt="Network Diagram" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A custom VPC with public and private subnets across 2 availability zones&lt;/li&gt;
&lt;li&gt;Two EC2 instances running the Docker Compose stack in a private subnet&lt;/li&gt;
&lt;li&gt;An Application Load Balancer handling all incoming traffic&lt;/li&gt;
&lt;li&gt;Layered security groups so the EC2 never communicates directly with the internet&lt;/li&gt;
&lt;li&gt;A fully functional application accessible via the ALB DNS name&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Pre-requisites
&lt;/h2&gt;

&lt;p&gt;Before deploying, make sure the following are ready:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. AWS Account
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account with administrative access&lt;/li&gt;
&lt;li&gt;AWS CLI configured (optional — we'll use the Console for everything)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Application Code
&lt;/h3&gt;

&lt;p&gt;The application repository with the Docker Compose configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Israel-dot-com/apartment-deployment-main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. EC2 Key Pair
&lt;/h3&gt;

&lt;p&gt;You'll need an EC2 key pair for SSH access:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EC2 Console&lt;/strong&gt; → &lt;strong&gt;Key Pairs&lt;/strong&gt; → &lt;strong&gt;Create key pair&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Name: &lt;code&gt;apartment&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Key pair type: RSA&lt;/li&gt;
&lt;li&gt;Private key format: &lt;code&gt;.pem&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Download and save the &lt;code&gt;.pem&lt;/code&gt; file securely&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Networking — Build the VPC Foundation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Overview
&lt;/h3&gt;

&lt;p&gt;We are going to create a custom VPC with public and private subnets across 2 availability zones for the application. Using the default VPC would put everything in public subnets with wide-open routing — fine for a quick test, not suitable for anything production-facing.&lt;/p&gt;

&lt;p&gt;What we'll create:&lt;/p&gt;

&lt;p&gt;We can create these a the same time from one interface on the Create VPC console.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPC&lt;/strong&gt; — Virtual Private Cloud with &lt;code&gt;10.0.0.0/16&lt;/code&gt; CIDR (65,536 IPs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public Subnets&lt;/strong&gt; — 2 subnets for internet-facing resources (ALB, NAT Gateway)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private Subnets&lt;/strong&gt; — 2 subnets for the application server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internet Gateway&lt;/strong&gt; — Internet access for public subnets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NAT Gateway&lt;/strong&gt; — Outbound-only internet for private subnets (Docker pulls, system updates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route Tables&lt;/strong&gt; — Traffic routing configuration&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  1.1 Create the VPC
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbj56tvgo4dp6owht7hgn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbj56tvgo4dp6owht7hgn.png" alt=" " width="800" height="554"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  1.2 Create the Subnets
&lt;/h3&gt;

&lt;p&gt;We need 4 subnets across 2 availability zones. The ALB requires subnets in at least 2 AZs, and the second private subnet provides room for future scaling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC Console&lt;/strong&gt; → &lt;strong&gt;Subnets&lt;/strong&gt; → &lt;strong&gt;Create subnet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Select VPC: &lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public Subnet A:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subnet name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-public-a&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability Zone&lt;/td&gt;
&lt;td&gt;us-east-1a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv4 CIDR block&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.1.0/24&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Add new subnet&lt;/strong&gt; to add the remaining three in one go:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public Subnet B:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subnet name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-public-b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability Zone&lt;/td&gt;
&lt;td&gt;us-east-1b&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv4 CIDR block&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.2.0/24&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Private Subnet A:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subnet name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-private-a&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability Zone&lt;/td&gt;
&lt;td&gt;us-east-1a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv4 CIDR block&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.11.0/24&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Private Subnet B:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subnet name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-private-b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability Zone&lt;/td&gt;
&lt;td&gt;us-east-1b&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv4 CIDR block&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.12.0/24&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Create subnet&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable auto-assign public IPv4 for public subnets:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For each public subnet (&lt;code&gt;apartment-public-a&lt;/code&gt; and &lt;code&gt;apartment-public-b&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Select the subnet → &lt;strong&gt;Actions&lt;/strong&gt; → &lt;strong&gt;Edit subnet settings&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Check &lt;strong&gt;Enable auto-assign public IPv4 address&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjmj8gc24qxmnqh3pwc3o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjmj8gc24qxmnqh3pwc3o.png" alt=" " width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  1.3 Create and Attach the Internet Gateway
&lt;/h3&gt;

&lt;p&gt;The Internet Gateway gives your public subnets a path to the internet. Without it, nothing in the VPC can reach the outside world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC Console&lt;/strong&gt; → &lt;strong&gt;Internet Gateways&lt;/strong&gt; → &lt;strong&gt;Create internet gateway&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name tag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-igw&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Create internet gateway&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now attach it to the VPC:&lt;/p&gt;

&lt;p&gt;Select &lt;code&gt;apartment-igw&lt;/code&gt; → &lt;strong&gt;Actions&lt;/strong&gt; → &lt;strong&gt;Attach to VPC&lt;/strong&gt; → select &lt;code&gt;apartment-vpc&lt;/code&gt; → &lt;strong&gt;Attach&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;State should show: &lt;code&gt;Attached&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Attached VPC: &lt;code&gt;apartment-vpc&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  1.4 Create the NAT Gateway
&lt;/h3&gt;

&lt;p&gt;The EC2 instance lives in a private subnet, no direct internet access. But it still needs to pull Docker images, install system packages, and download updates. The NAT Gateway provides outbound-only internet access. Traffic goes out, but nothing can initiate a connection in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC Console&lt;/strong&gt; → &lt;strong&gt;NAT Gateways&lt;/strong&gt; → &lt;strong&gt;Create NAT gateway&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-nat&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subnet&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apartment-public-a&lt;/code&gt; (must be a public subnet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connectivity type&lt;/td&gt;
&lt;td&gt;Public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elastic IP allocation ID&lt;/td&gt;
&lt;td&gt;Click &lt;strong&gt;Allocate Elastic IP&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Create NAT gateway&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; The NAT Gateway takes 1-2 minutes to become available. Wait for the status to change from &lt;code&gt;Pending&lt;/code&gt; to &lt;code&gt;Available&lt;/code&gt; before proceeding to route tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Save This Value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Elastic IP address (you'll need this for cleanup)&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  1.5 Create and Configure Route Tables
&lt;/h3&gt;

&lt;p&gt;We need two route tables with different routing rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public route table&lt;/strong&gt; — sends internet traffic through the Internet Gateway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private route table&lt;/strong&gt; — sends internet traffic through the NAT Gateway&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Public Route Table
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;VPC Console&lt;/strong&gt; → &lt;strong&gt;Route Tables&lt;/strong&gt; → &lt;strong&gt;Create route table&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-rt-public&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Create route table&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add route to Internet Gateway:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Select &lt;code&gt;apartment-rt-public&lt;/code&gt; → &lt;strong&gt;Routes&lt;/strong&gt; tab → &lt;strong&gt;Edit routes&lt;/strong&gt; → &lt;strong&gt;Add route&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0/0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select &lt;strong&gt;Internet Gateway&lt;/strong&gt; → &lt;code&gt;apartment-igw&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Save changes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Associate public subnets:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subnet associations&lt;/strong&gt; tab → &lt;strong&gt;Edit subnet associations&lt;/strong&gt; → select both &lt;code&gt;apartment-public-a&lt;/code&gt; and &lt;code&gt;apartment-public-b&lt;/code&gt; → &lt;strong&gt;Save associations&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Private Route Table
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;VPC Console&lt;/strong&gt; → &lt;strong&gt;Route Tables&lt;/strong&gt; → &lt;strong&gt;Create route table&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-rt-private&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Create route table&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add route to NAT Gateway:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Select &lt;code&gt;apartment-rt-private&lt;/code&gt; → &lt;strong&gt;Routes&lt;/strong&gt; tab → &lt;strong&gt;Edit routes&lt;/strong&gt; → &lt;strong&gt;Add route&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0/0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select &lt;strong&gt;NAT Gateway&lt;/strong&gt; → &lt;code&gt;apartment-nat&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Save changes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Associate private subnets:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subnet associations&lt;/strong&gt; tab → &lt;strong&gt;Edit subnet associations&lt;/strong&gt; → select both &lt;code&gt;apartment-private-a&lt;/code&gt; and &lt;code&gt;apartment-private-b&lt;/code&gt; → &lt;strong&gt;Save associations&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;[screenshot: route tables showing public→IGW and private→NAT routes]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify — Route Table Validation:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route Table&lt;/th&gt;
&lt;th&gt;Route &lt;code&gt;0.0.0.0/0&lt;/code&gt; →&lt;/th&gt;
&lt;th&gt;Associated Subnets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;apartment-rt-public&lt;/td&gt;
&lt;td&gt;apartment-igw&lt;/td&gt;
&lt;td&gt;apartment-public-a, apartment-public-b&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;apartment-rt-private&lt;/td&gt;
&lt;td&gt;apartment-nat&lt;/td&gt;
&lt;td&gt;apartment-private-a, apartment-private-b&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's the entire network foundation done. We have a VPC with proper separation between public and private resources, internet access through the IGW, and outbound-only access through the NAT for our private subnets.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Security Groups
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Overview
&lt;/h3&gt;

&lt;p&gt;We need two security groups that work together as layers. The key principle: &lt;strong&gt;the EC2 instance only accepts traffic from the ALB, never directly from the internet.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  2.1 Create ALB Security Group
&lt;/h3&gt;

&lt;p&gt;This security group allows HTTP and HTTPS traffic from anywhere — the ALB is the public-facing entry point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC Console&lt;/strong&gt; → &lt;strong&gt;Security Groups&lt;/strong&gt; → &lt;strong&gt;Create security group&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Security group name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-sg-alb&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description&lt;/td&gt;
&lt;td&gt;Allow HTTP/HTTPS inbound to ALB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Inbound rules&lt;/strong&gt; — Add rules:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0/0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP from internet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTPS&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0/0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTPS from internet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Outbound rules:&lt;/strong&gt; Leave default (All traffic → &lt;code&gt;0.0.0.0/0&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Create security group&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  2.2 Create EC2 Security Group
&lt;/h3&gt;

&lt;p&gt;This security group only allows traffic from the ALB — not from the public internet. This is the key security benefit of the architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC Console&lt;/strong&gt; → &lt;strong&gt;Security Groups&lt;/strong&gt; → &lt;strong&gt;Create security group&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Security group name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-sg-ec2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description&lt;/td&gt;
&lt;td&gt;Allow traffic from ALB and SSH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Inbound rules&lt;/strong&gt; — Add rules:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;Select &lt;strong&gt;&lt;code&gt;apartment-sg-alb&lt;/code&gt;&lt;/strong&gt; (security group)&lt;/td&gt;
&lt;td&gt;Traffic from ALB only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;My IP (or your specific IP/32)&lt;/td&gt;
&lt;td&gt;SSH access&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Outbound rules:&lt;/strong&gt; Leave default (All traffic → &lt;code&gt;0.0.0.0/0&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Create security group&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; For the HTTP rule, the source is the &lt;strong&gt;ALB security group ID&lt;/strong&gt;, not a CIDR block. This means even if someone discovers the EC2's private IP address, they cannot reach it directly. All traffic must flow through the load balancer.&lt;/p&gt;

&lt;p&gt;[screenshot: EC2 security group showing port 80 source as apartment-sg-alb]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify — Security Group Validation:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Security Group&lt;/th&gt;
&lt;th&gt;Inbound Rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;apartment-sg-alb&lt;/td&gt;
&lt;td&gt;80 from &lt;code&gt;0.0.0.0/0&lt;/code&gt;, 443 from &lt;code&gt;0.0.0.0/0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;apartment-sg-ec2&lt;/td&gt;
&lt;td&gt;80 from &lt;code&gt;apartment-sg-alb&lt;/code&gt;, 22 from your IP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbll1grzuv4uhdlzo22eb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbll1grzuv4uhdlzo22eb.png" alt=" " width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Launch EC2 Instance
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3.1 Launch the Instance
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;EC2 Console&lt;/strong&gt; → &lt;strong&gt;Instances&lt;/strong&gt; → &lt;strong&gt;Launch instances&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-server&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application and OS Images&lt;/td&gt;
&lt;td&gt;Ubuntu Server 24.04 LTS (HVM), SSD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture&lt;/td&gt;
&lt;td&gt;64-bit (x86)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instance type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t3.medium&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key pair&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apartment&lt;/code&gt; (created in pre-requisites)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Network settings&lt;/strong&gt; → Click &lt;strong&gt;Edit&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subnet&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-private-a&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-assign public IP&lt;/td&gt;
&lt;td&gt;Disable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security group&lt;/td&gt;
&lt;td&gt;Select existing → &lt;code&gt;apartment-sg-ec2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Configure storage:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;30 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume type&lt;/td&gt;
&lt;td&gt;gp3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encrypted&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Launch instance&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcevdrlf4pwerco83iuu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcevdrlf4pwerco83iuu.png" alt=" " width="800" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instance state: &lt;code&gt;Running&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Private IPv4: Should be in the &lt;code&gt;10.0.10.x&lt;/code&gt; range&lt;/li&gt;
&lt;li&gt;Public IPv4: None (this is correct — it's in a private subnet)&lt;/li&gt;
&lt;li&gt;Security group: &lt;code&gt;apartment-sg-ec2&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  3.2 SSH Access via Instance Connect Endpoint
&lt;/h3&gt;

&lt;p&gt;Since the EC2 is in a private subnet with no public IP, we cannot SSH directly. We'll use an &lt;strong&gt;EC2 Instance Connect Endpoint&lt;/strong&gt; — no bastion host required, no public IP needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC Console&lt;/strong&gt; → &lt;strong&gt;Endpoints&lt;/strong&gt; → &lt;strong&gt;Create endpoint&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name tag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-eice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service category&lt;/td&gt;
&lt;td&gt;EC2 Instance Connect Endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subnet&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-private-a&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-sg-ec2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Create endpoint&lt;/strong&gt;. Wait for the status to become &lt;code&gt;Available&lt;/code&gt; (takes 2-3 minutes).&lt;/p&gt;

&lt;p&gt;Connect to the instance via the Instance Endpoint Connect Endpoint&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4qkuh7y3y7sjuh8ro59.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4qkuh7y3y7sjuh8ro59.png" alt=" " width="800" height="749"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Install Prerequisites on EC2
&lt;/h2&gt;

&lt;p&gt;All we need on the server is Docker, Docker Compose, and Git. We can use this script to install everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="c"&gt;# Update system&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# Install Docker&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | &lt;span class="nb"&gt;sudo &lt;/span&gt;sh

&lt;span class="c"&gt;# Allow current user to run Docker without sudo&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
newgrp docker

&lt;span class="c"&gt;# Install Git&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; git

&lt;span class="c"&gt;# Verify installations&lt;/span&gt;
docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker compose version
git &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save this as &lt;code&gt;install.sh&lt;/code&gt;, give it permission with &lt;code&gt;chmod +x install.sh&lt;/code&gt;, and run it. Or just paste the commands directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify Installation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# Docker version 28.x.x&lt;/span&gt;

docker compose version
&lt;span class="c"&gt;# Docker Compose version v2.x.x&lt;/span&gt;

git &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# git version 2.x.x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: Deploy the Application
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5.1 Clone the Repository
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Israel-dot-com/apartment-deployment-main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apartment-deployment/
├── be-apartment/          # FastAPI backend
│   ├── api/               # Routes, models, schemas, services
│   ├── alembic/           # Database migrations
│   ├── Dockerfile
│   ├── .env.sample        # Example backend config
│   └── entrypoint.sh      # Runs migrations then starts Uvicorn
├── fe-apartment/          # React frontend
│   ├── src/
│   └── Dockerfile         # Multi-stage build → Nginx
├── nginx/                 # Reverse proxy config
│   └── nginx.conf
├── docker-compose.yml     # Orchestrates all services
└── .env                   # Root env (DB credentials)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  5.2 Configure Environment Files
&lt;/h3&gt;

&lt;p&gt;Here's the first gotcha. &lt;code&gt;.env&lt;/code&gt; files are in &lt;code&gt;.gitignore&lt;/code&gt;, as they should be, so they don't exist after cloning. If you skip this step and run &lt;code&gt;docker compose up&lt;/code&gt; directly, you'll see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;WARN[0000] The "DB_NAME" variable is not set. Defaulting to a blank string.
WARN[0000] The "DB_USER" variable is not set. Defaulting to a blank string.
WARN[0000] The "DB_PASSWORD" variable is not set. Defaulting to a blank string.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL starts with blank credentials, fails its health check, and every dependent service collapses. Not helpful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create the root &lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt; (used by the PostgreSQL service in &lt;code&gt;docker-compose.yml&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
PYTHON_ENV=prod

DB_TYPE=postgresql
DB_NAME=apartment
DB_USER=apartment
DB_PASSWORD=YourSecurePasswordHere

SECRET_KEY=generate-a-random-string-here
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=600
JWT_REFRESH_EXPIRY=30

APP_URL=http://localhost
FRONTEND_URL=http://localhost
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Generate a strong secret key:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the output as your &lt;code&gt;SECRET_KEY&lt;/code&gt; and &lt;code&gt;DB_PASSWORD&lt;/code&gt; values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create the backend &lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt; (used by the FastAPI application):&lt;/p&gt;

&lt;p&gt;Copy the sample file and update it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;be-apartment/.env.sample be-apartment/.env
nano be-apartment/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At minimum, make sure these values are set and match the root &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB_URL=postgresql://apartment:YourSecurePasswordHere@postgres:5432/apartment
SECRET_KEY=generate-a-random-string-here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; The &lt;code&gt;DB_PASSWORD&lt;/code&gt; in the root &lt;code&gt;.env&lt;/code&gt; must match the password in the &lt;code&gt;DB_URL&lt;/code&gt; in &lt;code&gt;be-apartment/.env&lt;/code&gt;. The hostname in &lt;code&gt;DB_URL&lt;/code&gt; must be &lt;code&gt;postgres&lt;/code&gt; — this is the Docker Compose service name, not &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  5.3 Build and Start the Stack
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first build takes a few minutes — Docker is pulling base images, installing Python dependencies (&lt;code&gt;pip install&lt;/code&gt;), building the React frontend (&lt;code&gt;npm run build&lt;/code&gt;), and creating all four container images. Subsequent builds use Docker's cache and are much faster.&lt;/p&gt;

&lt;p&gt;Wait about 30 seconds for health checks to complete, then check the status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME                    STATUS          PORTS
apartment-postgres      Up (healthy)    5432/tcp
apartment-backend-1     Up (healthy)    8000/tcp
apartment-frontend      Up              80/tcp
&lt;/span&gt;&lt;span class="gp"&gt;apartment-nginx         Up              0.0.0.0:80-&amp;gt;&lt;/span&gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All four services should show &lt;code&gt;Up&lt;/code&gt; with the postgres and backend containers showing &lt;code&gt;(healthy)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME                    STATUS          PORTS
apartment-postgres      Up (healthy)    5432/tcp
apartment-backend-1     Up (healthy)    8000/tcp
apartment-frontend      Up              80/tcp
&lt;/span&gt;&lt;span class="gp"&gt;apartment-nginx         Up              0.0.0.0:80-&amp;gt;&lt;/span&gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the backend logs to confirm migrations ran successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs backend &lt;span class="nt"&gt;--tail&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;backend-1  | Running database migrations...
&lt;/span&gt;&lt;span class="gp"&gt;backend-1  | INFO  [alembic.runtime.migration] Running upgrade  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;22962e293a83, apply existing migrations
&lt;span class="gp"&gt;backend-1  | INFO  [alembic.runtime.migration] Running upgrade 22962e293a83 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;a1b2c3d4e5f6, add phone_number and avatar_url
&lt;span class="go"&gt;backend-1  | ...
&lt;/span&gt;&lt;span class="gp"&gt;backend-1  | INFO  [alembic.runtime.migration] Running upgrade 486419238951 -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;c8f1b2e9a7d0, &lt;span class="nb"&gt;set &lt;/span&gt;property status default
&lt;span class="go"&gt;backend-1  | Starting FastAPI...
backend-1  | INFO:     Uvicorn running on http://0.0.0.0:8000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All migrations applied. FastAPI is running. But we still can't access the application from a browser — the EC2 is in a private subnet with no public IP. We need the load balancer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Application Load Balancer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Overview
&lt;/h3&gt;

&lt;p&gt;The ALB sits in the public subnets and forwards traffic to the EC2 in the private subnet. This is how the application becomes accessible from the internet while keeping the server isolated.&lt;/p&gt;




&lt;h3&gt;
  
  
  6.1 Create Target Group
&lt;/h3&gt;

&lt;p&gt;The target group tells the ALB where to send traffic and how to check if the target is healthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EC2 Console&lt;/strong&gt; → &lt;strong&gt;Load Balancing&lt;/strong&gt; → &lt;strong&gt;Target Groups&lt;/strong&gt; → &lt;strong&gt;Create target group&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Target type&lt;/td&gt;
&lt;td&gt;Instances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target group name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-tg&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol version&lt;/td&gt;
&lt;td&gt;HTTP1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Health checks:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Health check protocol&lt;/td&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health check path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Healthy threshold&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unhealthy threshold&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;td&gt;10 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interval&lt;/td&gt;
&lt;td&gt;30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Success codes&lt;/td&gt;
&lt;td&gt;200-399&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Next&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Register targets:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Select your &lt;code&gt;apartment-server&lt;/code&gt; instance → click &lt;strong&gt;Include as pending below&lt;/strong&gt; → click &lt;strong&gt;Create target group&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Wait 30-60 seconds, then verify the target shows as &lt;code&gt;healthy&lt;/code&gt; in the &lt;strong&gt;Targets&lt;/strong&gt; tab.&lt;/p&gt;




&lt;h3&gt;
  
  
  6.2 Create the ALB
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;EC2 Console&lt;/strong&gt; → &lt;strong&gt;Load Balancing&lt;/strong&gt; → &lt;strong&gt;Load Balancers&lt;/strong&gt; → &lt;strong&gt;Create load balancer&lt;/strong&gt; → &lt;strong&gt;Application Load Balancer&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Load balancer name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-alb&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheme&lt;/td&gt;
&lt;td&gt;Internet-facing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP address type&lt;/td&gt;
&lt;td&gt;IPv4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Network mapping:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apartment-vpc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mappings&lt;/td&gt;
&lt;td&gt;Select both: &lt;code&gt;apartment-public-a&lt;/code&gt; (us-east-1a) and &lt;code&gt;apartment-public-b&lt;/code&gt; (us-east-1b)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Security groups:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Remove the default security group. Select &lt;code&gt;apartment-sg-alb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Listeners and routing:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Default action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;Forward to &lt;code&gt;apartment-tg&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Click &lt;strong&gt;Create load balancer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Wait for the ALB state to change from &lt;code&gt;Provisioning&lt;/code&gt; to &lt;code&gt;Active&lt;/code&gt; (takes 2-3 minutes).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6sr5xpdrws7dcgtff82i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6sr5xpdrws7dcgtff82i.png" alt="ALB Creation" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahd527jxoj2l8eyzkviv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahd527jxoj2l8eyzkviv.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Optional: Add HTTPS
&lt;/h4&gt;

&lt;p&gt;If you have a domain name, you can add HTTPS with a free AWS certificate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ACM Console&lt;/strong&gt; (switch to &lt;code&gt;us-east-1&lt;/code&gt; if using CloudFront, otherwise use your ALB's region) → &lt;strong&gt;Request certificate&lt;/strong&gt; → &lt;strong&gt;Request a public certificate&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Add your domain name(s) → DNS validation → &lt;strong&gt;Request&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create records in Route 53&lt;/strong&gt; to validate automatically&lt;/li&gt;
&lt;li&gt;Wait for status: &lt;code&gt;Issued&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Go back to your ALB → &lt;strong&gt;Listeners&lt;/strong&gt; → &lt;strong&gt;Add listener&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Protocol: HTTPS, Port: 443&lt;/li&gt;
&lt;li&gt;Default action: Forward to &lt;code&gt;apartment-tg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Default SSL/TLS certificate: Select your ACM certificate&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Edit the HTTP:80 listener → change action to &lt;strong&gt;Redirect to HTTPS&lt;/strong&gt; port 443&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  6.3 Test the Application
&lt;/h3&gt;

&lt;p&gt;Open the ALB DNS name in your browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://apartment-alb-1060582062.us-east-1.elb.amazonaws.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Frontend loads&lt;br&gt;
Backend API responds at &lt;code&gt;/api/v1/&lt;/code&gt;&lt;br&gt;
User registration and login&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frroc1mwphx92ikyd5573.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frroc1mwphx92ikyd5573.png" alt=" " width="800" height="617"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: The Cleanup Problem
&lt;/h2&gt;

&lt;p&gt;The application is deployed and working. But now let's say we're done testing and want to tear everything down to stop paying for it.&lt;/p&gt;

&lt;p&gt;Here's what we have to delete, manually, in the correct dependency order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Console Location&lt;/th&gt;
&lt;th&gt;Wait Required?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;EC2 Instance&lt;/td&gt;
&lt;td&gt;EC2 → Instances → Terminate&lt;/td&gt;
&lt;td&gt;Wait for termination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Load Balancer&lt;/td&gt;
&lt;td&gt;EC2 → Load Balancers → Delete&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Target Group&lt;/td&gt;
&lt;td&gt;EC2 → Target Groups → Delete&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;NAT Gateway&lt;/td&gt;
&lt;td&gt;VPC → NAT Gateways → Delete&lt;/td&gt;
&lt;td&gt;⏳ Wait 1-2 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Elastic IP&lt;/td&gt;
&lt;td&gt;VPC → Elastic IPs → Release&lt;/td&gt;
&lt;td&gt;Must wait for NAT GW deletion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Instance Connect Endpoint&lt;/td&gt;
&lt;td&gt;VPC → Endpoints → Delete&lt;/td&gt;
&lt;td&gt;⏳ Wait 2-3 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Internet Gateway&lt;/td&gt;
&lt;td&gt;VPC → IGWs → Detach → Delete&lt;/td&gt;
&lt;td&gt;Must detach before deleting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Security Groups&lt;/td&gt;
&lt;td&gt;VPC → Security Groups → Delete&lt;/td&gt;
&lt;td&gt;Can't delete while ALB/EC2 exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Subnets&lt;/td&gt;
&lt;td&gt;VPC → Subnets → Delete&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Route Tables&lt;/td&gt;
&lt;td&gt;VPC → Route Tables → Delete&lt;/td&gt;
&lt;td&gt;Must remove associations first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;VPC&lt;/td&gt;
&lt;td&gt;VPC → Your VPCs → Delete&lt;/td&gt;
&lt;td&gt;Everything above must be gone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You have to delete them in order or the VPC deletion fails with a dependency error. Delete things in the wrong order and you get &lt;code&gt;DependencyViolation: resource has a dependent object&lt;/code&gt;. Try to delete the NAT Gateway and the Elastic IP at the same time and the EIP release fails because the NAT Gateway is still in the &lt;code&gt;deleting&lt;/code&gt; state.&lt;/p&gt;

&lt;p&gt;I spent a lot of time manually creating resources through the console, waiting for resources to finish deleting, and retrying failed deletions.&lt;/p&gt;

&lt;p&gt;It's tedious doing this every time you need to to scaffold the entire architecture. That's where Terraform comes in, Terraform is an infrastructure as code that lets you build, change, and version our target infrastructure safely and efficiently, in our next article we'll look into spinning up this project with Terraform&lt;/p&gt;

</description>
      <category>devops</category>
      <category>nextjs</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>Reviving DevOps</title>
      <dc:creator>Israel</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:59:46 +0000</pubDate>
      <link>https://dev.to/israeltheory/reviving-devops-1f4c</link>
      <guid>https://dev.to/israeltheory/reviving-devops-1f4c</guid>
      <description>&lt;p&gt;Okay, welcome back.&lt;br&gt;
Sometimes last week i built a quick project on AWS, a serverless data analysis platform using S3,AWS Glue, Athena and Quicksight. It was a refresher on AWS services.&lt;/p&gt;

&lt;p&gt;Gone back to the basics properly and for practice, i'll be building and writing about 6 small production-shaped projects exercising the almighty DevOps Lifecycle.&lt;/p&gt;

&lt;p&gt;First will be a production three tier Application then&lt;br&gt;
A CI/CD Production Pipeline&lt;br&gt;
A Highly Available Web Platform&lt;br&gt;
An Observability and Operations Platform&lt;br&gt;
An Infrastructure + GitOps Style Workflow&lt;br&gt;
&amp;amp; for my Magnum Opus, the Highly Available MedusaJS store i promised back last year &lt;a href="https://dev.to/israeltheory/deploying-a-medusajs-20-store-on-an-aws-ec2-instance-5ba5"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;See you.&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Building a Serverless Netflix Data Analytics Platform with AWS Glue, Athena and QuickSight.</title>
      <dc:creator>Israel</dc:creator>
      <pubDate>Mon, 17 Aug 2026 08:04:50 +0000</pubDate>
      <link>https://dev.to/israeltheory/building-a-serverless-netflix-data-analytics-platform-with-aws-glue-athena-and-quicksight-d1d</link>
      <guid>https://dev.to/israeltheory/building-a-serverless-netflix-data-analytics-platform-with-aws-glue-athena-and-quicksight-d1d</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick Data &amp;amp; Cloud Engineering project&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hey Guys, Today we'll be building a serverless data analytics platform using AWS services.&lt;/p&gt;

&lt;p&gt;Our goal is pretty straight forward, We want to take a Netflix dataset in CSV format, store it in Amazon S3, catalogue and transform it using AWS Glue, query the processed data using Amazon Athena, and finally create interactive dashboards using Amazon QuickSight, now called Quick, i think.&lt;/p&gt;

&lt;p&gt;The idea is to simulate how companies handle analytics workloads without managing servers or traditional database infrastructure and to get a personal feel of how these services work.&lt;/p&gt;

&lt;p&gt;Again, First, we store our raw Netflix dataset inside Amazon S3. Then we use AWS Glue to discover the structure of the data and create metadata using the Glue Data Catalog.&lt;/p&gt;

&lt;p&gt;After that, we create an ETL pipeline that cleans the dataset and converts it from CSV into Parquet format. Parquet is much better suited for analytics workloads because it is compressed and column-based and it takes less time to query than CSV as we'll see.&lt;/p&gt;

&lt;p&gt;Once the data is processed, Amazon Athena allows us to query it using SQL. Finally, Amazon QuickSight connects to Athena and turns those queries into dashboards and visual insights.&lt;/p&gt;

&lt;p&gt;Simple enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Introduction&lt;/li&gt;
&lt;li&gt;Step-by-Step Setup

&lt;ul&gt;
&lt;li&gt;STEP 1) Setting Up the S3 Data Lake&lt;/li&gt;
&lt;li&gt;STEP 2) Creating the AWS Glue Data Catalog and Crawler&lt;/li&gt;
&lt;li&gt;STEP 3) Building the AWS Glue ETL Pipeline&lt;/li&gt;
&lt;li&gt;STEP 4) Building Analytics with Athena and QuickSight&lt;/li&gt;
&lt;li&gt;STEP 5) Next Steps (Automate with S3 Event Notification and EventBridge)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Setting Up the S3 Data Lake
&lt;/h2&gt;

&lt;p&gt;The first step is creating the storage layer.&lt;/p&gt;

&lt;p&gt;Amazon S3 will act as our data lake.&lt;/p&gt;

&lt;p&gt;We separate our data into different stages on the S3 Bucket&lt;/p&gt;

&lt;p&gt;Raw data&lt;br&gt;
Processed data&lt;br&gt;
Athena query results&lt;/p&gt;

&lt;p&gt;This makes the workflow easier to understand and maintain.&lt;/p&gt;

&lt;p&gt;Our bucket structure looks like&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;netflix-analytics-data-lake/

├── raw/
│   └── netflix_titles.csv
│
├── processed/
│
└── athena-results/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw folder contains the original csv file.&lt;/p&gt;

&lt;p&gt;After creating your S3 Bucket&lt;/p&gt;

&lt;p&gt;Block all public access&lt;br&gt;
Enable Bucket versioning &amp;amp;&lt;br&gt;
Server-side encryption&lt;/p&gt;

&lt;p&gt;The dataset is uploaded into &lt;code&gt;raw/netflix_titles.csv&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y0mozyiggwbkyrx9ygw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y0mozyiggwbkyrx9ygw.png" alt="Raw CSV file S3 upload" width="799" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creating the AWS Glue Data Catalog and Crawler&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Create a database, &lt;code&gt;netflix_analytics&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0fed7kiwxrgt930a3yfm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0fed7kiwxrgt930a3yfm.png" alt="AWS Glue Crawler" width="800" height="218"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This database will contain our tables.&lt;/p&gt;

&lt;p&gt;Create an IAM Role for Glue&lt;/p&gt;

&lt;p&gt;The role requires permissions to:&lt;/p&gt;

&lt;p&gt;Read from S3&lt;br&gt;
Write metadata&lt;br&gt;
Create Glue tables&lt;/p&gt;

&lt;p&gt;You should ideally use the least privilege required for the task but for now we'll use Full Access.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AmazonS3FullAccess&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Creating the Glue Crawler&lt;/p&gt;

&lt;p&gt;A crawler automatically discovers the structure of our dataset.&lt;/p&gt;

&lt;p&gt;Navigate to:&lt;/p&gt;

&lt;p&gt;AWS Glue and create a Crawler and name it &lt;code&gt;netflix-raw-data-crawler&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The Data source would be the S3 Bucket you created earlier, &lt;code&gt;netflix_analytics&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Table prefix, &lt;code&gt;raw_&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe90ryvxxo6af1o3ifohx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe90ryvxxo6af1o3ifohx.png" alt="Glue Crawler Creation" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run the crawler.&lt;/p&gt;

&lt;p&gt;Glue scans the CSV file and creates a table.&lt;/p&gt;

&lt;p&gt;Next, you Test the Glue Table with Athena&lt;/p&gt;

&lt;p&gt;Before building the ETL(Extract, Transform, Load) pipeline, we verify that Athena can read the table.&lt;/p&gt;

&lt;p&gt;Open Athena and select the database and run, &lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT *&lt;br&gt;
FROM raw_netflix_titles&lt;br&gt;
LIMIT 10;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If everything works, we should see Netflix records.&lt;/p&gt;

&lt;p&gt;At this point, we have successfully connected:&lt;/p&gt;

&lt;p&gt;S3 to Glue and Glue to Athena&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Building the AWS Glue ETL Pipeline&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now we move from transforming the raw data into processed data.&lt;br&gt;
The Goal in this step is to convert, the CSV file into Parquet format/file.&lt;/p&gt;

&lt;p&gt;Creating the Glue Visual ETL Job&lt;/p&gt;

&lt;p&gt;On the AWS Glue page, navigate to Visual ETL on the sidebar,&lt;/p&gt;

&lt;p&gt;Create a new job. &lt;code&gt;netflix-csv-to-parquet&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The default IAM role it suggests is missing a couple roles, &lt;code&gt;s3:GetObject&lt;br&gt;
s3:PutObject&lt;br&gt;
s3:ListBucket&lt;/code&gt; add these and you won't run into the error i ran into.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AWSGlueServiceRole-netflix-analytics&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Here we want to add a source, our S3 bucket take the table, apply a mapping to it, drop empty fields, and add a destination to drop the finished parquet file&lt;/p&gt;

&lt;h1&gt;
  
  
  Adding the Source
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F449mlg6taoo39ouzdd0s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F449mlg6taoo39ouzdd0s.png" alt="Add Source" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Applying Schema Mapping
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpd8eg8nnruif9qfpmrg9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpd8eg8nnruif9qfpmrg9.png" alt="Apply Schema Mapping" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This mapping handles our schema changes.&lt;/p&gt;

&lt;h1&gt;
  
  
  DropNullFields
&lt;/h1&gt;

&lt;p&gt;Optional but this removes fields where every value is empty.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fafh7jxj07vuw4tkcwvqm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fafh7jxj07vuw4tkcwvqm.png" alt="Drop Null Field" width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Configuration:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Enable:&lt;br&gt;
Empty String&lt;br&gt;
"null" String&lt;/code&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Writing Parquet Output
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvq2jh00u1qd36y7p6fv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvq2jh00u1qd36y7p6fv.png" alt="Parquet Processed" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;s3://your-bucket/processed/netflix_titles/&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Run the Glue job and the output should look like this after a few minutes&lt;/p&gt;

&lt;p&gt;&lt;code&gt;processed/netflix_titles/part-00000-snappy.parquet&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;A Small IAM Challenge&lt;/p&gt;

&lt;p&gt;During the first run, the Glue job might fail.&lt;/p&gt;

&lt;p&gt;The error:&lt;/p&gt;

&lt;p&gt;not authorized to perform: s3:PutObject&lt;/p&gt;

&lt;p&gt;Easily fixable by adding &lt;code&gt;s3:GetObject&lt;br&gt;
s3:PutObject&lt;br&gt;
s3:ListBucket&lt;/code&gt; to your gluerole&lt;br&gt;
Fixed and it works perfectly.&lt;/p&gt;

&lt;h1&gt;
  
  
  4. Building Analytics with Athena and QuickSight
&lt;/h1&gt;

&lt;p&gt;Now we have clean Parquet data.&lt;/p&gt;

&lt;p&gt;The Next step is Querying the Processed Data with Athena&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jlsioni1kh1evct1dro.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jlsioni1kh1evct1dro.png" alt="Athena" width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This Query tells us the Top Producing countries.&lt;/p&gt;

&lt;p&gt;SELECT&lt;br&gt;
country,&lt;br&gt;
COUNT(*) AS total_titles&lt;br&gt;
FROM processed_netflix_titles&lt;br&gt;
GROUP BY country&lt;br&gt;
ORDER BY total_titles DESC&lt;br&gt;
LIMIT 10;&lt;/p&gt;

&lt;p&gt;If you run a query with the raw csv vs the parquet file, you'll see the parquet is faster which is the entire point of the transformation. &lt;/p&gt;

&lt;p&gt;Now we create the dashboard layer.&lt;/p&gt;

&lt;p&gt;Create a Quick Account,&lt;/p&gt;

&lt;p&gt;Add a New Dataset, Select Athena, Choose the database and processed table&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffx8j82zjje70bxtf9urh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffx8j82zjje70bxtf9urh.png" alt="AWS Quick" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Import into SPICE and you can now you can create your dashboard&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmnofo2u3iualuhlo5lzh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmnofo2u3iualuhlo5lzh.png" alt="AWS Quick Dashboard" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can create visualisations here that answer specific questions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjvnhpc3zcejzc7pqw1go.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjvnhpc3zcejzc7pqw1go.png" alt="AWS Quick Dashboard" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Visual 1: Content Distribution&lt;/p&gt;

&lt;p&gt;Question:&lt;/p&gt;

&lt;p&gt;What type of content dominates Netflix?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frczdma4s3pfx8bos2awi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frczdma4s3pfx8bos2awi.png" alt="AWS Quick Piechart and Line chart Dashboard" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next Steps&lt;/p&gt;

&lt;p&gt;The current pipeline is a batch analytics workflow.&lt;/p&gt;

&lt;p&gt;The next step would be automating this workflow with EventBridge and S3 Event Notifications. I'll do that in a seperate article. See you soon.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>analytics</category>
      <category>devops</category>
    </item>
    <item>
      <title>Deploying a MedusaJS 2.0 Store on AN AWS EC2 Instance.</title>
      <dc:creator>Israel</dc:creator>
      <pubDate>Fri, 26 Sep 2025 20:36:27 +0000</pubDate>
      <link>https://dev.to/israeltheory/deploying-a-medusajs-20-store-on-an-aws-ec2-instance-5ba5</link>
      <guid>https://dev.to/israeltheory/deploying-a-medusajs-20-store-on-an-aws-ec2-instance-5ba5</guid>
      <description>&lt;h2&gt;
  
  
  So you have decided to set up an online store and you would rather set it up your self, you're in the right place.
&lt;/h2&gt;

&lt;p&gt;We'll Deploy a MedusaJS Backend and Storefront on an Ubuntu Server. In a later article we'll look at using other AWS services to build a more resilient version. I'll link it here when it's ready [link]&lt;/p&gt;

&lt;h1&gt;
  
  
  Table of Contents
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;Introduction&lt;/li&gt;
&lt;li&gt;Pre-requisites&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Step-by-Step Deployment&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;STEP 1) Server Provisioning&lt;/li&gt;
&lt;li&gt;STEP 2) Installing Prerequisites&lt;/li&gt;
&lt;li&gt;STEP 3) Running the Medusa Backend&lt;/li&gt;
&lt;li&gt;STEP 4) Reverse Proxy with Nginx&lt;/li&gt;
&lt;li&gt;STEP 5) Setting up the Frontend&lt;/li&gt;
&lt;li&gt;STEP 6) Debugging and Fixes&lt;/li&gt;
&lt;li&gt;STEP 7) Next Steps (Future Work)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Why Deploy MedusaJS on a Scalable Server?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are many modern, headless commerce platforms, and MedusaJS is one of the most versatile for building full-featured online stores. Medusa makes store development seamless but  deploying a production-ready application can be challenging without the right setup and automation practices.&lt;/p&gt;

&lt;p&gt;That’s what this article will focus on, configuring an Ubuntu server to handle it and serving it through Nginx so safe to say that by the end we'll have&lt;/p&gt;

&lt;p&gt;A live MedusaJS application running on your server&lt;/p&gt;

&lt;p&gt;Clearly separated and functional routes for admin, API, authentication, and storefront&lt;/p&gt;

&lt;p&gt;A persistent frontend using PM2 for reliability&lt;/p&gt;

&lt;p&gt;Nginx properly configured to handle the admin panel, country-specific routes and SSL&lt;/p&gt;

&lt;p&gt;So whether you’re a developer looking to build more features for a specific use-case or just exploring headless commerce, this step-by-step guide will give you everything you need to host Medusa yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Provisioning
&lt;/h2&gt;

&lt;p&gt;Start by provisioning an ubuntu server, we'll create a new security group with inbound rules; 22 from your IP, 80 and 443 from anywhere. &lt;br&gt;
You should have something like this&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5ow69iimgpnms5fddsqn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5ow69iimgpnms5fddsqn.png" alt=" " width="800" height="205"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A t3 medium server with about 30GB of storage space should do just fine.&lt;/p&gt;
&lt;h2&gt;
  
  
  Installing the Prerequisites
&lt;/h2&gt;

&lt;p&gt;All we need is a Server, a domain and a stripe account.&lt;/p&gt;

&lt;p&gt;We'll need to install quite a few things to get our Medusa Store up and running.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Node&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Docker &amp;amp; Docker Compose&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Curl &amp;amp; Git&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PM2&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Nginx&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Certbot&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We can use this script to install everything we need. &lt;br&gt;
We'll install the Certbot later since it requires interaction&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/bin/bash

# Update system
sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y

# Install prerequisites
sudo apt install -y ca-certificates curl gnupg lsb-release

# Add Docker’s official GPG key
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# Add Docker’s APT repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null

# Install Docker Engine, CLI, containerd, and Docker Compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

# Enable and start Docker
sudo systemctl enable docker
sudo systemctl start docker

# Allow current user to run Docker without sudo
sudo usermod -aG docker $USER
newgrp docker

# Verify Docker
docker --version
docker compose version

# Install Git
sudo apt install -y git

# Install Nginx
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx

# Install Node.js (LTS) and npm
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs

# Install PM2 globally
sudo npm install -g pm2

# Setup PM2 startup (so it restarts apps on reboot)
pm2 startup systemd -u $USER --hp $HOME

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Running the Backend Server
&lt;/h2&gt;

&lt;p&gt;We'll set up the Medusa Backend using Docker.&lt;/p&gt;

&lt;p&gt;Let's begin by cloning the Repo&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git clone https://github.com/medusajs/medusa-starter-default.git --depth=1 medusa-server&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
Next, create a Docker compose file, &lt;code&gt;docker-compose.yml&lt;/code&gt; in the new repo&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;services:
  # PostgreSQL Database
  postgres:
    image: postgres:15-alpine
    container_name: medusa_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: medusa-store
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - medusa_network

  # Redis
  redis:
    image: redis:7-alpine
    container_name: medusa_redis
    restart: unless-stopped
    ports:
      - "6379:6379"
    networks:
      - medusa_network

  # Medusa Server
  # This service runs the Medusa backend application
  # and the admin dashboard.
  medusa:
    build: .
    container_name: medusa_backend
    restart: unless-stopped
    depends_on:
      - postgres
      - redis
    ports:
      - "9000:9000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgres://postgres:postgres@postgres:5432/medusa-store
      - REDIS_URL=redis://redis:6379
    env_file:
      - .env
    volumes:
      - .:/app
      - /app/node_modules
    networks:
      - medusa_network

volumes:
  postgres_data:

networks:
  medusa_network:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates three new services we'll need, Redis, Postgres and Medusa&lt;/p&gt;

&lt;p&gt;Create a Dockerfile, &lt;code&gt;Dockerfile&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Development Dockerfile for Medusa
FROM node:20-alpine

# Set working directory
WORKDIR /server

# Copy package files and npm config
COPY package.json package-lock.json ./

# Install all dependencies using npm
RUN npm install

# Copy source code
COPY . .

# Expose the port Medusa runs on
EXPOSE 9000

# Start with migrations and then the development server
CMD ["./start.sh"]

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now create the start script &lt;code&gt;start.sh&lt;/code&gt; and give it permission to run with &lt;code&gt;chmod u+x start.sh&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/bin/sh

# Run migrations and start server
echo "Running database migrations..."
npx medusa db:migrate

echo "Seeding database..."
npm run seed || echo "Seeding failed, continuing..."

echo "Starting Medusa development server..."
npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;npm install&lt;/code&gt; to install the dependencies we'll be working with.&lt;/p&gt;

&lt;p&gt;Replace the &lt;code&gt;medusa-config.ts&lt;/code&gt; file with this&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { loadEnv, defineConfig } from '@medusajs/framework/utils'

loadEnv(process.env.NODE_ENV || 'production', process.cwd())

module.exports = defineConfig({
  projectConfig: {
    databaseUrl: process.env.DATABASE_URL,
    databaseDriverOptions: {
      ssl: false,
      sslmode: "disable",
    },

    http: {
      storeCors: process.env.STORE_CORS!,
      adminCors: process.env.ADMIN_CORS!,
      authCors: process.env.AUTH_CORS!,
      jwtSecret: process.env.JWT_SECRET || "supersecret",
      cookieSecret: process.env.COOKIE_SECRET || "supersecret",
    },
  },

  admin: {
    vite: () =&amp;gt; {
      return {
        server: {
          allowedHosts: [".domainhere.com"], // add your domain here
        },
      }
    },
  },
})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't forget to replace with your domain name where i indicated&lt;/p&gt;

&lt;p&gt;In the package.json script section add this&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{&lt;br&gt;
  "scripts": {&lt;br&gt;
    // Other scripts...&lt;br&gt;
    "docker:up": "docker compose up --build -d",&lt;br&gt;
    "docker:down": "docker compose down"&lt;br&gt;
  }&lt;br&gt;
}&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;Create a &lt;code&gt;.dockerignore&lt;/code&gt; file and add these to it&lt;/p&gt;

&lt;p&gt;&lt;code&gt;node_modules&lt;br&gt;
npm-debug.log*&lt;br&gt;
yarn-debug.log*&lt;br&gt;
yarn-error.log*&lt;br&gt;
.git&lt;br&gt;
.gitignore&lt;br&gt;
README.md&lt;br&gt;
.env.test&lt;br&gt;
.nyc_output&lt;br&gt;
coverage&lt;br&gt;
.DS_Store&lt;br&gt;
*.log&lt;br&gt;
dist&lt;br&gt;
build&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Next create a .env file and add these, replacing with your domain where necessary&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STORE_CORS=http://localhost:8000,https://docs.medusajs.com,https://domainhere.com,https://www.domainhere.com
ADMIN_CORS=http://localhost:5173,http://localhost:9000,https://docs.medusajs.com,https://domainhere.com,https://domainhere.com/app
AUTH_CORS=http://localhost:5173,http://localhost:9000,https://docs.medusajs.com,https://domainhere.com,https://domainhere.com/app
REDIS_URL=redis://redis:6379
JWT_SECRET=supersecret
COOKIE_SECRET=supersecret
DATABASE_URL=postgres://postgres:postgres@postgres:5432/medusa-store
DB_NAME=medusa-v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never expose your secrets but it should look like this &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjyde6oh1lbnszblin5c4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjyde6oh1lbnszblin5c4.png" alt=" " width="798" height="163"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To start your application use &lt;code&gt;npm run docker:up&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;To create a new admin user run&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose run --rm medusa npx medusa user -e admin@example.com -p supersecret&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Replace with your email and password&lt;/p&gt;

&lt;p&gt;To check your logs if everything runs smoothly&lt;br&gt;
&lt;code&gt;docker compose logs -f&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Reverse Proxy with Nginx
&lt;/h2&gt;

&lt;p&gt;Now let's setup Nginx&lt;/p&gt;

&lt;p&gt;We've installed Nginx previously so we should be seeing the "Welcome to Nginx page" on the server's url&lt;/p&gt;

&lt;p&gt;Create a Config file in /etc/nginx/sites-available and name it anything you want. I used the name of my domain, workrate.online&lt;/p&gt;

&lt;p&gt;This config file sets up redirects to your Admin dashboard, frontend and API&lt;br&gt;
Just replace my domain name, workrate.online with yours&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server {
    listen 80;
    server_name workrate.online www.workrate.online;

    # --- Frontend (Next.js Storefront) ---
    location ~ ^/(dk|us|ng)(/.*)?$ {
        proxy_pass http://localhost:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # Root frontend fallback
    location / {
        proxy_pass http://localhost:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # --- Medusa Backend Routes ---
    location /store/ {
        proxy_pass http://localhost:9000/store/;
    }

    location /admin/ {
        proxy_pass http://localhost:9000/admin/;
    }

    location /auth/ {
        proxy_pass http://localhost:9000/auth/;
    }

    location /app/ {
        proxy_pass http://localhost:9000/app/;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable the newly created config with &lt;br&gt;
&lt;code&gt;sudo ln -s /etc/nginx/sites-available/yourfilenamehere/etc/nginx/sites-enabled/&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Remove the default config, it sometimes interfere&lt;br&gt;
&lt;code&gt;sudo rm /etc/nginx/sites-enabled/default&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Test and reload your config &lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo nginx -t&lt;br&gt;
sudo systemctl reload nginx&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Don't forget to point your domain to your server with your DNS provider.&lt;/p&gt;

&lt;p&gt;Also you should set up Certbot to provide HTTPS by running &lt;/p&gt;

&lt;p&gt;sudo apt install -y certbot python3-certbot-nginx&lt;br&gt;
sudo certbot --nginx -d yourdomainname.com -d &lt;a href="http://www.yourdomainname.com" rel="noopener noreferrer"&gt;www.yourdomainname.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You should now be able to access your Admin panel from your domain &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyrjdpwswiojjyz1lbrsd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyrjdpwswiojjyz1lbrsd.png" alt=" " width="799" height="475"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting up the frontend
&lt;/h2&gt;

&lt;p&gt;We'll be setting up a GitHub Actions pipeline with the frontend because it seems more practical, the backend almost never changes but the frontend will require a lot of UI work and therefore constant pushing. &lt;/p&gt;

&lt;p&gt;Anyways, let's start by cloning the storefront&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git clone https://github.com/medusajs/nextjs-starter-medusa medusa-storefront&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;npm install&lt;/code&gt; to install all dependencies&lt;/p&gt;

&lt;p&gt;Create a .env file&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#Your Medusa backend, should be updated to where you are hosting your server. Remember to update CORS settings for your server. See – https://docs.medusajs.com/learn/configurations/medusa-config#httpstorecors
MEDUSA_BACKEND_URL=http://localhost:9000

# Your publishable key that can be attached to sales channels. See - https://docs.medusajs.com/resources/storefront-development/publishable-api-keys
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=

# Your store URL, should be updated to where you are hosting your storefront.
NEXT_PUBLIC_BASE_URL=http://localhost:8000

# Your preferred default region. When middleware cannot determine the user region from the "x-vercel-country" header, the default region will be used. ISO-2 lowercase format. 
NEXT_PUBLIC_DEFAULT_REGION=dk

# Your Stripe public key. See – https://docs.medusajs.com/resources/commerce-modules/payment/payment-provider/stripe
NEXT_PUBLIC_STRIPE_KEY=pk_test_dQZtCPCXzFgnPf7YRp1ETSs7

# Your Next.js revalidation secret. See – https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#on-demand-revalidation
REVALIDATE_SECRET=supersecret

NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need your Publishable key and Stripe Key {You can use the test one i attached for development purpose only}&lt;/p&gt;

&lt;p&gt;You can use SSH tunnelling to access your port 9000 through your browser, login and get your Publishable Key. Run this on your local machine not the server&lt;/p&gt;

&lt;p&gt;Or just go through your domain/app&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ssh -i /path/to/your-key.pem -L 9000:localhost:9000 ubuntu@&amp;lt;your-ec2-public-ip&amp;gt;&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You should then be able to go to &lt;code&gt;http://localhost:9000/app/settings/publishable-api-keys&lt;/code&gt; to retrieve your own keys&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;npm run dev&lt;/code&gt; to make sure everything is running smoothly&lt;/p&gt;
&lt;h2&gt;
  
  
  GitHub Action
&lt;/h2&gt;

&lt;p&gt;Generate new secret keys on your local machine with &lt;br&gt;
&lt;code&gt;ssh-keygen -t rsa -b 4096 -C "example@email.com"&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
Follow the prompt and manually append your generated .pub keys to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Create a GitHub repo, &lt;code&gt;Medusa-storefront&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Copy the private keys and go over to your Github Repo &lt;/p&gt;

&lt;p&gt;Navigate to Settings -&amp;gt; Secrets &amp;amp; Variables -&amp;gt; Actions&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8b6x8dmhby462adxdp8p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8b6x8dmhby462adxdp8p.png" alt=" " width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create a new repository secret named SSH_PRIVATE_KEY and enter the keys you copied earlier.&lt;/p&gt;

&lt;p&gt;Remember the values from the .env files, we'll create some secrets and variables so we can SSH into over server, build the frontend and deploy it every time it detects a push.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SSH_PRIVATE_KEY  Secret &lt;br&gt;
SERVER_HOST Secret Your Public IPv4 Address&lt;br&gt;
SERVER_USER Secret  ubuntu&lt;br&gt;
APP_DIR Secret  /var/www/storefront&lt;br&gt;
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY  Secret&lt;br&gt;
NEXT_PUBLIC_STRIPE_KEY  Secret &lt;br&gt;
REVALIDATE_SECRET   Secret&lt;br&gt;
MEDUSA_BACKEND_URL  Variable https://domainname&lt;br&gt;
NEXT_PUBLIC_MEDUSA_BACKEND_URL  Variable https://domainname&lt;br&gt;
NEXT_PUBLIC_BASE_URL    Variable https://domainname&lt;br&gt;
NEXT_PUBLIC_DEFAULT_REGION  Variable dk&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;Next in your server create a file in the store-front directory in &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Add this to the file save and push to your own local repo&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Deploy Frontend

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    env:
      MEDUSA_BACKEND_URL: ${{ vars.MEDUSA_BACKEND_URL }}
      NEXT_PUBLIC_MEDUSA_BACKEND_URL: ${{ vars.NEXT_PUBLIC_MEDUSA_BACKEND_URL }}
      NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
      NEXT_PUBLIC_DEFAULT_REGION: ${{ vars.NEXT_PUBLIC_DEFAULT_REGION }}
      NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY }}
      NEXT_PUBLIC_STRIPE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_KEY }}
      REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 20

      - name: Create .env file
        run: |
          cat &amp;gt; .env &amp;lt;&amp;lt; EOF
          MEDUSA_BACKEND_URL=${{ vars.MEDUSA_BACKEND_URL }}
          NEXT_PUBLIC_MEDUSA_BACKEND_URL=${{ vars.NEXT_PUBLIC_MEDUSA_BACKEND_URL }}
          NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
          NEXT_PUBLIC_DEFAULT_REGION=${{ vars.NEXT_PUBLIC_DEFAULT_REGION }}
          NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY }}
          NEXT_PUBLIC_STRIPE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_KEY }}
          REVALIDATE_SECRET=${{ secrets.REVALIDATE_SECRET }}
          EOF
      - name: Install dependencies and build
        run: |
          npm install
          npm run build
      - name: Copy build files and .env to server
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: ".,!.git"
          target: "${{ secrets.APP_DIR }}"

      - name: Restart app
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd ${{ secrets.APP_DIR }}
            # Install production dependencies
            sudo npm install
            # Restart PM2 process
            sudo pm2 delete medusa-storefront || true
            sudo pm2 start npm --name medusa-"storefront" -- run start
            sudo pm2 save

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use these commands to make sure the APP_DIR exists and has the correct permission to run&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;br&gt;
sudo mkdir -p /var/www/storefront&lt;br&gt;
sudo chown -R ubuntu:ubuntu /var/www/storefront&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging, Fixes and Additional Configs
&lt;/h2&gt;

&lt;p&gt;All you have left to do is make a change to the frontend, i overhauled the Storefront landing page. Make a push, let it build and test to make sure everything works fine.&lt;br&gt;
Check it out here.&lt;br&gt;
&lt;a href="https://github.com/aregbesolaisrael/medusa-storefront" rel="noopener noreferrer"&gt;https://github.com/aregbesolaisrael/medusa-storefront&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Steps
&lt;/h2&gt;

&lt;p&gt;While this setup works for small and medium businesses, in my next article i'll cover scaling Medusa with AWS services (ECS, RDS, CloudFront) and setting up monitoring and alerts.&lt;/p&gt;

</description>
      <category>ecommerce</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Configuring Nginx on an EC2 Instance.</title>
      <dc:creator>Israel</dc:creator>
      <pubDate>Thu, 30 Jan 2025 17:48:18 +0000</pubDate>
      <link>https://dev.to/israeltheory/configuring-nginx-on-an-ec2-instance-1ihb</link>
      <guid>https://dev.to/israeltheory/configuring-nginx-on-an-ec2-instance-1ihb</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Hey there, today we'll be doing a quick DevOps task, setting up Nginx (A web server) on an Ubuntu server. Nothing too fancy.&lt;br&gt;
If you're new welcome to my Blog, I'm Israel, a developer and DevOps Engineer looking for excitement and challenges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Task
&lt;/h2&gt;

&lt;p&gt;The task is to set up an Ubuntu server, configure Nginx on it and have it serve a custom page as it's default page. .&lt;/p&gt;

&lt;h2&gt;
  
  
  The Plan
&lt;/h2&gt;

&lt;p&gt;We are going to use an AWS account to provision the Ubuntu server, login to the instance via SSH, install Nginx on it then create the custom page to be displayed then finally test it to see if it works. Simple enough, let's get to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Musing
&lt;/h2&gt;

&lt;p&gt;A big part about being a DevOps engineer is optimization. That's almost the entire thing, optimization and efficiency and continuously improving and reevaluating said process. I wrote an article about what DevOps engineers do, you can check it out at &lt;a href="https://medium.com/@israeltheory/devops-the-big-picture-c925da59d08f" rel="noopener noreferrer"&gt;The Big Picture&lt;/a&gt; and if you're looking for engineers you can check out &lt;a href="https://hng.tech/hire/devops-engineers" rel="noopener noreferrer"&gt;DevOps Engineers&lt;/a&gt; &amp;amp; &lt;a href="https://hng.tech/hire/cloud-engineers" rel="noopener noreferrer"&gt;Cloud Engineers&lt;/a&gt; for rockstar professionals.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Setting up the Ubuntu server with AWS
&lt;/h2&gt;

&lt;p&gt;To do this we'll head over to &lt;a href="https://aws.amazon.com/" rel="noopener noreferrer"&gt;AWS's Website&lt;/a&gt; then find your way to the console at the top right hand corner. It should look like this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ew6yzddy7nvqf7oqi1c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ew6yzddy7nvqf7oqi1c.png" alt="AWS Console" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now we're going to search for EC2 and launch an Ubuntu instance with settings that'll serve our purpose and cost the least, cost optimization, remember ;).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Name the instance and select Ubuntu on the quick start tab&lt;/li&gt;
&lt;li&gt;Select t2.micro and create a keypair, I like to use .pem ones.&lt;/li&gt;
&lt;li&gt;Next i created a security group and allowed HTTP/HTTPS traffic from the internet and ssh traffic from my IP. &lt;/li&gt;
&lt;li&gt;create and access the server with
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ssh -i &amp;lt;yourkeypair.pem&amp;gt; ubuntu@&amp;lt;serveripaddress&amp;gt; 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but don't forget to give execution permissions with chmod u+x keypair.pem&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Installing and configuring Nginx
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Now we are in the server we can install Nginx with these commands. &lt;br&gt;
you can just run them in a script but it's advisable to first put them in one by one to get a feel of how it's done&lt;br&gt;
&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install -y nginx&lt;br&gt;
sudo systemctl start nginx&lt;br&gt;
sudo systemctl enable nginx&lt;/code&gt;&lt;br&gt;
These will install Nginx, confirm it's running and make sure it automatically runs when you restart.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Navigate to the Nginx web directory with&lt;br&gt;
&lt;code&gt;cd /var/www/html&lt;/code&gt;&lt;br&gt;
open a new index.html file with &lt;br&gt;
&lt;code&gt;vi index.html&lt;/code&gt;&lt;br&gt;
and paste a basic html file &lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;save and exit vim with :wq and the earlier command ensures Nginx automatically restarts and you can see changes to your site at once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4ws7i0bap6zpnlfrpwch.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4ws7i0bap6zpnlfrpwch.png" alt="Updated page" width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenges &amp;amp; Contribution to learning
&lt;/h2&gt;

&lt;p&gt;This was a joy to work on, taking me back to my early days when tasks like these took me more than 5 mins to work on. I had trouble deciding whether to use a domain name or not, i went with no because it just was easier.&lt;br&gt;
Now i had fun writing this article and it reignited the fire i once had for writing, so now i plan to write more articles and fully commit to a writing style. Thanks HNG&lt;/p&gt;

</description>
      <category>devops</category>
      <category>nginx</category>
    </item>
  </channel>
</rss>
