<?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: KithupaG</title>
    <description>The latest articles on DEV Community by KithupaG (@trenation).</description>
    <link>https://dev.to/trenation</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%2F1623578%2F2a7b831c-6be6-45f9-bee7-c18d3a997278.jpg</url>
      <title>DEV Community: KithupaG</title>
      <link>https://dev.to/trenation</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/trenation"/>
    <language>en</language>
    <item>
      <title>Building a Production Grade AWS Infrastructure Project (Part 5): RDS</title>
      <dc:creator>KithupaG</dc:creator>
      <pubDate>Fri, 24 Jul 2026 14:57:36 +0000</pubDate>
      <link>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-5-rds-g3n</link>
      <guid>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-5-rds-g3n</guid>
      <description>&lt;h2&gt;
  
  
  Continuing the Build
&lt;/h2&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-3-iam-terraform-243a"&gt;previous post&lt;/a&gt;, I set up the security group module that acts as the network firewall for everything in the VPC. With networking and permissions sorted, the next logical piece is the database.&lt;/p&gt;

&lt;p&gt;The architecture plan is coming together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC → IAM → Security Groups → RDS → ALB → EC2 ASG → S3+CDN → Monitoring → CI/CD&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The backend needs a database, and RDS is the managed choice. It's relatively self-contained once it has a subnet group and security group reference, both of which we now have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why RDS Now?
&lt;/h2&gt;

&lt;p&gt;You might wonder why not ALB or EC2 first. The dependency chain is straightforward: RDS needs a VPC (for subnets) and a security group (for network access). Both are done. The ALB needs the VPC and SG too, but the ASG also needs the ALB target group to attach to. RDS is the leaf node in the dependency tree, nothing else depends on it being ready first, so it unblocks the database layer early.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Module Structure
&lt;/h2&gt;

&lt;p&gt;I created a dedicated &lt;code&gt;modules/rds&lt;/code&gt; directory. The module interface is intentionally minimal:&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;variable&lt;/span&gt; &lt;span class="s2"&gt;"security_group"&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;"subnet_ids"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's it. Two inputs, the security group ID (wrapped in a list because AWS expects a list) and the private subnet IDs for the DB subnet group.&lt;/p&gt;
&lt;h2&gt;
  
  
  Configuring the Database Instance
&lt;/h2&gt;

&lt;p&gt;First, the basics: identifier, engine, and storage.&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;identifier&lt;/span&gt;     &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres-db-dev"&lt;/span&gt;
&lt;span class="nx"&gt;db_name&lt;/span&gt;        &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"notetaker"&lt;/span&gt;
&lt;span class="nx"&gt;engine&lt;/span&gt;         &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt;
&lt;span class="nx"&gt;engine_version&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"16.3"&lt;/span&gt;
&lt;span class="nx"&gt;allocated_storage&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Pin the minor version.&lt;/strong&gt; I learned this the hard way in other projects — if you don't pin &lt;code&gt;16.3&lt;/code&gt;, Terraform will pick up &lt;code&gt;16.4&lt;/code&gt; or &lt;code&gt;16.5&lt;/code&gt; on a fresh apply, and minor version upgrades can introduce subtle behavior changes. In production, you want explicit control.&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;username&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt;
&lt;span class="nx"&gt;port&lt;/span&gt;     &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Standard PostgreSQL defaults. The username &lt;code&gt;postgres&lt;/code&gt; is the default superuser, in production you'd create a dedicated application user with limited privileges, but for this project the default is fine.&lt;/p&gt;
&lt;h2&gt;
  
  
  Parameter Groups: The "Advanced Settings" Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;By default, AWS launches RDS with stock configuration. Parameter groups let you tune the database engine behavior. The community module I'm using (&lt;code&gt;terraform-aws-modules/rds/aws&lt;/code&gt;) defaults to MySQL parameters, so I had to swap them out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original (MySQL defaults):&lt;/strong&gt;&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;parameters&lt;/span&gt; &lt;span class="err"&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"character_set_client"&lt;/span&gt;&lt;span class="p"&gt;,&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;"utf8mb4"&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;"character_set_server"&lt;/span&gt;&lt;span class="p"&gt;,&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;"utf8mb4"&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;Changed to PostgreSQL-appropriate logging:&lt;/strong&gt;&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;parameters&lt;/span&gt; &lt;span class="err"&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"log_connections"&lt;/span&gt;&lt;span class="p"&gt;,&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;"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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"log_disconnections"&lt;/span&gt;&lt;span class="p"&gt;,&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;"1"&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;Why these? Connection logging is invaluable for debugging connection pool exhaustion, leaked connections, or unexpected traffic patterns. These parameters are PostgreSQL-specific, MySQL uses different parameter names entirely. The module's defaults assumed MySQL because that's what the example configuration shipped with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; Parameter groups are engine-specific. If you switch from MySQL to PostgreSQL (or upgrade major versions), you need a new parameter group family (&lt;code&gt;postgres16&lt;/code&gt; vs &lt;code&gt;mysql8.0&lt;/code&gt;).&lt;/p&gt;
&lt;h2&gt;
  
  
  Option Groups: The Audit Plugin Situation
&lt;/h2&gt;

&lt;p&gt;The module also included a MariaDB audit plugin by default:&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;options&lt;/span&gt; &lt;span class="err"&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;option_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"MARIADB_AUDIT_PLUGIN"&lt;/span&gt;
    &lt;span class="nx"&gt;option_settings&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;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SERVER_AUDIT_EVENTS"&lt;/span&gt;&lt;span class="p"&gt;,&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;"CONNECT"&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;"SERVER_AUDIT_FILE_ROTATIONS"&lt;/span&gt;&lt;span class="p"&gt;,&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;"37"&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;&lt;strong&gt;I removed this entirely.&lt;/strong&gt; It's a MariaDB-specific plugin. PostgreSQL has its own auditing extensions (&lt;code&gt;pgaudit&lt;/code&gt;), but they require a different setup — you enable them via &lt;code&gt;shared_preload_libraries&lt;/code&gt; in the parameter group, not through an option group. For this project, connection logging via parameter group is sufficient.&lt;/p&gt;

&lt;p&gt;Lesson: Don't blindly copy example configurations. The community module's defaults are MySQL/MariaDB-centric.&lt;/p&gt;
&lt;h2&gt;
  
  
  Monitoring Interval: Integer, Not String
&lt;/h2&gt;

&lt;p&gt;Small but frustrating gotcha:&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;monitoring_interval&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;  &lt;span class="c1"&gt;# integer, not "30"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The Terraform provider expects a number. Passing a string causes a type mismatch error at apply time. The example in the module docs showed it as a string, which cost me a debug cycle.&lt;/p&gt;
&lt;h2&gt;
  
  
  Subnet Groups and Security Groups: The Wiring
&lt;/h2&gt;

&lt;p&gt;This is where the module connects to the rest of the infrastructure:&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;vpc_security_group_ids&lt;/span&gt; &lt;span class="err"&gt;=&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;security_group&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nx"&gt;subnet_ids&lt;/span&gt;             &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subnet_ids&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The security group variable is a string, but &lt;code&gt;vpc_security_group_ids&lt;/code&gt; expects a list. Wrapping it in &lt;code&gt;[var.security_group]&lt;/code&gt; satisfies the type requirement.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;subnet_ids&lt;/code&gt; comes from the VPC module's private subnets output — these are the subnets without a route to the internet gateway, which is exactly where a database should live.&lt;/p&gt;
&lt;h2&gt;
  
  
  Module Version Pinning
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"rds"&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;"terraform-aws-modules/rds/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;"6.0.0"&lt;/span&gt;
  &lt;span class="c1"&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;Always pin module versions.&lt;/strong&gt; If you don't specify a version, Terraform pulls the latest on every init. A module update could introduce breaking changes — renamed variables, changed defaults, removed outputs, and your pipeline would fail mysteriously. Pin to a specific version (or use a version constraint like &lt;code&gt;~&amp;gt; 6.0&lt;/code&gt;) and upgrade intentionally.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wiring It Into the Environment
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;environments/dev/main.tf&lt;/code&gt;:&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;module&lt;/span&gt; &lt;span class="s2"&gt;"dev_rds"&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;"../../modules/rds"&lt;/span&gt;
  &lt;span class="nx"&gt;security_group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dev_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main_sg&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dev_app_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnets&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The security group comes from the security group module output (&lt;code&gt;main_sg&lt;/code&gt;), and the private subnets come from the VPC module. This is the dependency chain in action, the RDS module can't be applied until both of those exist.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parameter groups are engine-specific.&lt;/strong&gt; Don't copy MySQL configs for PostgreSQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Option groups ≠ parameter groups.&lt;/strong&gt; Options are for engine features (audit plugins, TDE, etc.); parameters are for engine configuration (logging, memory, timeouts).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin module versions.&lt;/strong&gt; Always. No exceptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security group IDs need list wrapping.&lt;/strong&gt; &lt;code&gt;vpc_security_group_ids&lt;/code&gt; expects &lt;code&gt;list(string)&lt;/code&gt;, not &lt;code&gt;string&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minor version pinning matters.&lt;/strong&gt; &lt;code&gt;engine_version = "16.3"&lt;/code&gt; not &lt;code&gt;"16"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring interval is a number.&lt;/strong&gt; Not a string. The provider will yell at you.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What's Next: The ALB Module
&lt;/h2&gt;

&lt;p&gt;With the database ready, the next piece is the Application Load Balancer. The ALB sits in front of the compute layer (EC2 Auto Scaling Group or ECS), terminates TLS, and routes traffic to healthy targets. It needs the VPC, subnets, and security group, all of which are now in place. That's coming up next.&lt;/p&gt;



&lt;p&gt;Want to follow along? The full code is available here:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KithupaG" rel="noopener noreferrer"&gt;
        KithupaG
      &lt;/a&gt; / &lt;a href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;
        aws-production-infra
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A complete AWS themed production infrastructure
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;CloudNotes - Production Grade AWS Infrastructure&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A full-stack Note Taker application designed as a sandbox for building production-grade AWS infrastructure with Terraform. The app is fully containerized with Docker and orchestrated with Docker Compose, ready to be deployed across a high-availability AWS architecture.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Architecture&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;                        ┌──────────────────────────────────┐
                        │         AWS Cloud (Target)       │
                        │                                  │
                        │   Route53 ─── CloudFront ─── S3  │
                        │                    │             │
                        │                    ▼             │
                        │        ┌─────── ALB ───────┐     │
                        │        │                   │     │
                        │   ┌────▼────┐        ┌────▼────┐ │
                        │   │ EC2 ASG │        │ EC2 ASG │ │
                        │   │(Backend)│        │(Backend)│ │
                        │   └────┬────┘        └────┬────┘ │
                        │        │                  │      │
                        │        └────────┬─────────┘      │
                        │                 ▼                │
                        │           ┌──── RDS ────┐        │
                        │           │  PostgreSQL │        │
                        │           └─────────────┘        │
                        └──────────────────────────────────┘
Local (Docker Compose):
┌──────────┐    ┌────────────┐    ┌──────────────┐
│ Frontend │───▶│  Backend  │───▶│  PostgreSQL  │
│ (Nginx)  │    │ (Node.js)  │    │ (15-alpine)  │
│  :3000   │    │  :5001     │    │   :5432&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



</description>
      <category>rds</category>
      <category>beginners</category>
      <category>aws</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Building a Production Grade AWS Infrastructure Project (Part 4): Security Groups Terraform</title>
      <dc:creator>KithupaG</dc:creator>
      <pubDate>Tue, 21 Jul 2026 16:13:06 +0000</pubDate>
      <link>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-4-security-groups-terraform-1cim</link>
      <guid>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-4-security-groups-terraform-1cim</guid>
      <description>&lt;h2&gt;
  
  
  Continuing the build
&lt;/h2&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-3-iam-terraform-243a"&gt;previous post&lt;/a&gt;, I set up IAM roles for the ECS agent and the application. With permissions in place, the next piece of the puzzle is security groups.&lt;/p&gt;

&lt;p&gt;Security groups are the firewall rules for your VPC. Without them, nothing launches RDS, ALB, ECS tasks, none of it. Every AWS service that touches the network needs a security group reference, so this had to come early.&lt;/p&gt;

&lt;p&gt;If you want to follow along with the full series, here's the architecture plan:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC -&amp;gt; IAM -&amp;gt; Security Groups -&amp;gt; RDS -&amp;gt; ALB -&amp;gt; EC2 ASG -&amp;gt; S3+CDN -&amp;gt; Monitoring -&amp;gt; CI/CD&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Security Group?
&lt;/h2&gt;

&lt;p&gt;A security group is a stateful firewall that controls inbound and outbound traffic for resources inside a VPC. Think of it as a bouncer at a door, it decides what gets in and what gets out.&lt;/p&gt;

&lt;p&gt;The key thing to understand: security groups are &lt;strong&gt;stateful&lt;/strong&gt;. If you allow inbound traffic on port 5000, the response traffic on that connection is automatically allowed back out, regardless of your egress rules. You don't need to open both directions manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting from the Terraform template
&lt;/h2&gt;

&lt;p&gt;I started by copying the AWS provider's example template:&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_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"allow_tls"&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;"allow_tls"&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 TLS inbound traffic and all outbound traffic"&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;"allow_tls"&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_vpc_security_group_ingress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"allow_tls_ipv4"&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;allow_tls&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_ipv4&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;cidr_block&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;443&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;to_port&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&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_egress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"allow_all_traffic_ipv4"&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;allow_tls&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_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="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;There are three resources here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The security group itself&lt;/strong&gt; — the container for the rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingress rule&lt;/strong&gt; — controls what traffic can come in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress rule&lt;/strong&gt; — controls what traffic can go out&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The template was a starting point. I needed to replace it with rules that actually match my application.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Security Group Module
&lt;/h2&gt;

&lt;p&gt;I created a dedicated &lt;code&gt;modules/security/&lt;/code&gt; directory with a &lt;code&gt;main.tf&lt;/code&gt; file. The module takes three variables:&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;variable&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&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;"cidr_cidr_block"&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;"environment"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vpc_id&lt;/strong&gt; — which VPC does this security group belong to?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cidr_cidr_block&lt;/strong&gt; — the CIDR range of the VPC, used to allow internal traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;environment&lt;/strong&gt; — for tagging purposes (dev, prod, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the security group resource:&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_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"ap_sg"&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;"app_security_group"&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;"Security group for the task manager application"&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;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_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;"allow_tls_ssh"&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="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;Nothing fancy here. The important part is that &lt;code&gt;vpc_id&lt;/code&gt; is set from the variable, meaning this module doesn't hardcode a VPC, it gets passed in from the environment configuration. This makes the module reusable across dev and prod.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ingress Rules: What gets in
&lt;/h2&gt;

&lt;p&gt;Now for the actual firewall rules. I created separate ingress resources for each port the application needs:&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_vpc_security_group_ingress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"application_frontend"&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;ap_sg&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_ipv4&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;cidr_cidr_block&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;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;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="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;"allow_SSH"&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;ap_sg&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_ipv4&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;cidr_cidr_block&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;22&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;to_port&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;22&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;"allow_backend_access"&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;ap_sg&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_ipv4&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;cidr_cidr_block&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;5000&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;to_port&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&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;"allow_database_access"&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;ap_sg&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_ipv4&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;cidr_cidr_block&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;5432&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;to_port&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Breaking down each rule:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Why it's open&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;Frontend (HTTP)&lt;/td&gt;
&lt;td&gt;The React app served by Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;SSH&lt;/td&gt;
&lt;td&gt;For remote access and debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5000&lt;/td&gt;
&lt;td&gt;Backend (Node.js)&lt;/td&gt;
&lt;td&gt;Frontend-to-backend communication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5432&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Backend-to-database communication&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  CIDR Blocks: Who can reach these ports?
&lt;/h2&gt;

&lt;p&gt;You'll notice every ingress rule uses &lt;code&gt;var.cidr_cidr_block&lt;/code&gt; as the &lt;code&gt;cidr_ipv4&lt;/code&gt; value. This is the CIDR range of the VPC (something like &lt;code&gt;10.0.0.0/16&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This means only resources &lt;strong&gt;inside the VPC&lt;/strong&gt; can reach these ports. The private subnets (&lt;code&gt;10.0.1.x&lt;/code&gt;, &lt;code&gt;10.0.2.x&lt;/code&gt;, &lt;code&gt;10.0.101.x&lt;/code&gt;) all fall within the &lt;code&gt;10.0.0.0/16&lt;/code&gt; range, so they're allowed. But traffic from the public internet is blocked.&lt;/p&gt;

&lt;p&gt;If you wanted to allow traffic from anywhere on the internet, you'd use:&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;cidr_ipv4&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's every IP address on earth. For a production app, you generally don't want that on your database or backend ports. Keeping everything scoped to the VPC CIDR means services can only talk to each other internally.&lt;/p&gt;
&lt;h2&gt;
  
  
  Egress Rules: What gets out
&lt;/h2&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_vpc_security_group_egress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"allow_all_traffic_ipv4"&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;ap_sg&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_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="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I enabled all outbound traffic. The &lt;code&gt;-1&lt;/code&gt; protocol means "all protocols" — TCP, UDP, ICMP, everything.&lt;/p&gt;

&lt;p&gt;Why allow everything out? Because the application needs to reach external services: pulling Docker images from ECR, downloading system updates, connecting to CloudWatch for logging. If you lock down egress too aggressively, things break in ways that are hard to debug.&lt;/p&gt;

&lt;p&gt;For most applications, allowing all outbound is the practical choice. You can always tighten it later if your threat model demands it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Connecting the Module to the Environment
&lt;/h2&gt;

&lt;p&gt;The security group module doesn't know about your VPC or environment variables until you wire them up. In the environment's &lt;code&gt;main.tf&lt;/code&gt;:&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;module&lt;/span&gt; &lt;span class="s2"&gt;"dev_sg"&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;"../../modules/security"&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;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dev_app_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_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;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is where the dependency chain becomes clear. The security group needs the VPC ID, so the VPC module has to be created first. That's why the architecture order matters, you can't build security groups before the VPC exists.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Outputs
&lt;/h2&gt;

&lt;p&gt;Without an outputs file, the security group is trapped inside its module. No other module can reference it.&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;output&lt;/span&gt; &lt;span class="s2"&gt;"main_sg"&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;"Main sg for the application to access tls, ssh, and allow all outbound connections"&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_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ap_sg&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;output&lt;/span&gt; &lt;span class="s2"&gt;"vpc_cidr_output"&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;"Outputs cidr value for vpc"&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_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ap_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_cidr_block&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;main_sg&lt;/strong&gt; — exports the security group ID so RDS, ALB, and ECS can reference it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vpc_cidr_output&lt;/strong&gt; — exports the CIDR block for use in other networking rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The security group ID is what gets passed into every future module that needs network access. RDS needs it for its own security group, the ALB needs it to define listener rules, and ECS tasks need it for their network configuration.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I learnt
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Security groups are stateful, you only need to define one direction for a connection&lt;/li&gt;
&lt;li&gt;Separate ingress rules per port makes the config readable and auditable&lt;/li&gt;
&lt;li&gt;Scoping CIDR to the VPC range keeps everything internal by default&lt;/li&gt;
&lt;li&gt;Always allow all egress unless you have a specific reason not to — debugging blocked outbound traffic is painful&lt;/li&gt;
&lt;li&gt;Modules need outputs to be useful, a security group that can't be referenced is just dead code&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Next up: The RDS module
&lt;/h2&gt;

&lt;p&gt;With the security group in place, we can finally set up the database. RDS needs a subnet group and a security group reference, both of which we now have.&lt;/p&gt;

&lt;p&gt;If you've ever had to debug a security group rule that was too restrictive, or forgotten to output a module value and spent an hour wondering why Terraform couldn't find it, drop a comment below!&lt;/p&gt;

&lt;p&gt;Want to follow along? The full code is available here:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KithupaG" rel="noopener noreferrer"&gt;
        KithupaG
      &lt;/a&gt; / &lt;a href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;
        aws-production-infra
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A complete AWS themed production infrastructure
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;CloudNotes - Production Grade AWS Infrastructure&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A full-stack Note Taker application designed as a sandbox for building production-grade AWS infrastructure with Terraform. The app is fully containerized with Docker and orchestrated with Docker Compose, ready to be deployed across a high-availability AWS architecture.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Architecture&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;                        ┌──────────────────────────────────┐
                        │         AWS Cloud (Target)       │
                        │                                  │
                        │   Route53 ─── CloudFront ─── S3  │
                        │                    │             │
                        │                    ▼             │
                        │        ┌─────── ALB ───────┐     │
                        │        │                   │     │
                        │   ┌────▼────┐        ┌────▼────┐ │
                        │   │ EC2 ASG │        │ EC2 ASG │ │
                        │   │(Backend)│        │(Backend)│ │
                        │   └────┬────┘        └────┬────┘ │
                        │        │                  │      │
                        │        └────────┬─────────┘      │
                        │                 ▼                │
                        │           ┌──── RDS ────┐        │
                        │           │  PostgreSQL │        │
                        │           └─────────────┘        │
                        └──────────────────────────────────┘
Local (Docker Compose):
┌──────────┐    ┌────────────┐    ┌──────────────┐
│ Frontend │───▶│  Backend  │───▶│  PostgreSQL  │
│ (Nginx)  │    │ (Node.js)  │    │ (15-alpine)  │
│  :3000   │    │  :5001     │    │   :5432&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



</description>
      <category>aws</category>
      <category>devops</category>
      <category>automation</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Building a Production Grade AWS Infrastructure Project (Part 3): IAM Terraform</title>
      <dc:creator>KithupaG</dc:creator>
      <pubDate>Mon, 20 Jul 2026 10:10:23 +0000</pubDate>
      <link>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-3-iam-terraform-243a</link>
      <guid>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-3-iam-terraform-243a</guid>
      <description>&lt;h2&gt;
  
  
  Building a Production Grade AWS Infrastructure Part 3: IAM Roles
&lt;/h2&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-2-containerization-pkg"&gt;previous post&lt;/a&gt;, I containerized the frontend and backend using Docker Compose with Postgres. Now that the app runs locally, it's time to start building the AWS infrastructure.&lt;/p&gt;

&lt;p&gt;But where do you even begin? AWS has a deny-by-default permissions model, meaning nothing works until you explicitly give it permission. That makes IAM the logical starting point, before you can spin up a VPC, launch an ECS task, or write to S3, you need roles and policies in place, otherwise all your services will fail from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The IAM Mindset: Roles, not Users
&lt;/h2&gt;

&lt;p&gt;When I first started learning IAM, my mind automatically went to users, creating someone a username, password, access keys. I had to remind myself: this is infrastructure, not a team. In production, your services don't log in. They assume roles.&lt;/p&gt;

&lt;p&gt;The difference is important:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IAM User&lt;/strong&gt; : person with a password and/or access keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM Role&lt;/strong&gt; : a set of permissions that a service can assume temporarily&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two are backed by the same core concept: an IAM identity. It's just that roles are designed for machines and services, not people. Since our app runs on AWS infrastructure, roles are the way to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  trust_policy_permissions: what?
&lt;/h2&gt;

&lt;p&gt;When you look at the role block in Terraform, the first thing you'll see is the trust policy:&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;trust_policy_permissions&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;TrustRoleAndServiceToAssume&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;actions&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:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"sts:TagSession"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;principals&lt;/span&gt; &lt;span class="p"&gt;=&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="s2"&gt;"Service"&lt;/span&gt;
        &lt;span class="nx"&gt;identifiers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="s2"&gt;"ecs-tasks.amazonaws.com"&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;&lt;code&gt;TrustRoleAndServiceToAssume&lt;/code&gt; is just a friendly label given by the terraform module, it could be labeled anything. Like &lt;code&gt;TrustThisServicePlease&lt;/code&gt;. The important part is the actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;sts:AssumeRole&lt;/strong&gt; : This tells AWS to issue a temporary security credential (valid for about an hour by default) to the service specified in the principals block. It's how AWS allows one identity to borrow another identity's permissions temporarily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sts:TagSession&lt;/strong&gt; : More of an auditing tool. This attaches metadata tags to that session so you can trace which service, container, or user requested the credentials. Very useful for debugging later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now here's something beginners often get wrong — the principals block. I initially had:&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;type&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS"&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="s2"&gt;"Service"&lt;/span&gt;
&lt;span class="nx"&gt;identifiers&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ec2.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="s2"&gt;"ecs-tasks.amazonaws.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Use &lt;code&gt;type = "Service"&lt;/code&gt; when the entity that needs the role is a service like EC2, ECS, or Lambda. Use &lt;code&gt;type = "AWS"&lt;/code&gt; when it's a user or account.&lt;/p&gt;

&lt;p&gt;I also removed the condition block:&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;condition&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="nx"&gt;test&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"StringEquals"&lt;/span&gt;
    &lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:ExternalId"&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;"some-secret-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;This block is specifically for external services, for example, if a third-party SaaS needs to access your AWS account. The ExternalId ensures that even if someone steals your role ARN, they can't use it without that secret ID. Since our application runs on ECS, which is an internal service, we don't need this.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why two roles? The ECS role architecture
&lt;/h2&gt;

&lt;p&gt;A common beginner mistake is to create a single role for everything. But if you're using ECS, there is a much better split:&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Task Execution Role (&lt;code&gt;app-execution-role&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;This role belongs to the ECS agent, it's the AWS service itself. Its job is to bridge the gap between the container specification and the services ECS needs to start your app.&lt;/p&gt;

&lt;p&gt;Policies attached:&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;policies&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;getSecretsPostgres&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AWSSecretsManagerClientReadOnlyAccess"&lt;/span&gt;
    &lt;span class="nx"&gt;pullImageFromECS&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AmazonECSTaskExecutionRolePolicy&lt;/strong&gt; : Allows the ECS agent to pull images from ECR and write logs to CloudWatch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWSSecretsManagerClientReadOnlyAccess&lt;/strong&gt; : Allows the ECS agent to read secrets from Secrets Manager&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think of this as the valet parking guy: he parks your car (the container) in the right spot, but he doesn't drive it anywhere.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Task Role (&lt;code&gt;app-task-role&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;This role belongs to the application itself, the code running inside the container. If your Node.js app needs to read an S3 bucket, send an email via SES, or access DynamoDB, this is where you attach those permissions.&lt;/p&gt;

&lt;p&gt;But here's the thing: right now, our application only talks to PostgreSQL via standard network drivers. It doesn't need IAM permissions.&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;policies&lt;/span&gt; &lt;span class="err"&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;Notice the empty policies block. I intentionally left it that way.&lt;/p&gt;

&lt;p&gt;If I didn't set a separate task role, the application would default to the host's IAM role (the EC2 instance's role), which could accidentally grant broad permissions. By explicitly setting an empty task role, we lock down the container: even if someone exploits a vulnerability in the Node application, there are zero permissions to leverage.&lt;/p&gt;

&lt;p&gt;Later, when the app grows and it needs to upload user avatars to S3, or send welcome emails via SES, I'll attach the appropriate policies.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to decide on policies
&lt;/h2&gt;

&lt;p&gt;Settling on the right policies requires two questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. What AWS service does my app touch?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My app is a Node.js application backed by PostgreSQL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compute: ECS with Fargate (needs to pull images from ECR)&lt;/li&gt;
&lt;li&gt;Database: Amazon RDS (connection goes through the network, not IAM)&lt;/li&gt;
&lt;li&gt;Security: AWS Secrets Manager (to retrieve db credentials)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. What actions does my app need for each service?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pulling images from ECR? Read-only -&amp;gt; &lt;code&gt;AmazonEC2ContainerRegistryReadOnly&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Writing logs? Write -&amp;gt; &lt;code&gt;CloudWatchAgentServerPolicy&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The golden rule: the fewer permissions, the better. Start with nothing and add as you go. AWS provides plenty of managed policies, but if none fits perfectly, you can always write a custom one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Custom policies with data sources
&lt;/h2&gt;

&lt;p&gt;If you ever need a policy that doesn't exist in AWS's managed offerings, Terraform provides the &lt;code&gt;aws_iam_policy_document&lt;/code&gt; data source:&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;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"example"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt;   &lt;span class="p"&gt;=&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="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&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="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="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;You can then reference &lt;code&gt;module.iam_policy.arn&lt;/code&gt; or build inline policies. The key thing is: write exactly what your app needs, nothing more.&lt;/p&gt;
&lt;h2&gt;
  
  
  The outputs file: exposing your roles
&lt;/h2&gt;

&lt;p&gt;Once the IAM roles are defined, your other modules can't see them without an outputs file. Think of it as an export statement.&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;output&lt;/span&gt; &lt;span class="s2"&gt;"execution_role_arn"&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;"The ARN of the ECS execution role"&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;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iam_role&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;output&lt;/span&gt; &lt;span class="s2"&gt;"task_role_arn"&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;"The ARN of the ECS task role"&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;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iam_role_task&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;.arn&lt;/strong&gt; tells Terraform to fetch the resource name string generated by AWS once the role is created&lt;/li&gt;
&lt;li&gt;The outputs will be referenced in the ECS module later&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never output plaintext secrets&lt;/strong&gt; like database passwords. If you absolutely must, use &lt;code&gt;sensitive = true&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now our VPC module (which I'll cover in the next post) and the ECS module will be able to link to these IAM role ARNs programmatically, without copy-pasting random string IDs.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I learnt
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;IAM roles are for services, IAM users are for people — two sides of the same coin&lt;/li&gt;
&lt;li&gt;ECS needs two distinct roles: one for the ECS agent and one for the app&lt;/li&gt;
&lt;li&gt;Empty task policies are a feature: they keep the app in the most secure posture possible&lt;/li&gt;
&lt;li&gt;Export via outputs, never hardcode ARNs&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Next up: The VPC module
&lt;/h2&gt;

&lt;p&gt;With IAM out of the way, the next logical step is setting up the VPC. All the networking, subnets, route tables, internet gateways. Needs to be defined before we can plug in RDS, ALB, or ECS.&lt;/p&gt;

&lt;p&gt;If you're learning IAM roles for the first time like me, what AWS service tripped you up the most? Drop a comment below!&lt;/p&gt;

&lt;p&gt;Want to follow along? The full code is available here:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KithupaG" rel="noopener noreferrer"&gt;
        KithupaG
      &lt;/a&gt; / &lt;a href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;
        aws-production-infra
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A complete AWS themed production infrastructure
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;CloudNotes - Production Grade AWS Infrastructure&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A full-stack Note Taker application designed as a sandbox for building production-grade AWS infrastructure with Terraform. The app is fully containerized with Docker and orchestrated with Docker Compose, ready to be deployed across a high-availability AWS architecture.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Architecture&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;                        ┌──────────────────────────────────┐
                        │         AWS Cloud (Target)       │
                        │                                  │
                        │   Route53 ─── CloudFront ─── S3  │
                        │                    │             │
                        │                    ▼             │
                        │        ┌─────── ALB ───────┐     │
                        │        │                   │     │
                        │   ┌────▼────┐        ┌────▼────┐ │
                        │   │ EC2 ASG │        │ EC2 ASG │ │
                        │   │(Backend)│        │(Backend)│ │
                        │   └────┬────┘        └────┬────┘ │
                        │        │                  │      │
                        │        └────────┬─────────┘      │
                        │                 ▼                │
                        │           ┌──── RDS ────┐        │
                        │           │  PostgreSQL │        │
                        │           └─────────────┘        │
                        └──────────────────────────────────┘
Local (Docker Compose):
┌──────────┐    ┌────────────┐    ┌──────────────┐
│ Frontend │───▶│  Backend  │───▶│  PostgreSQL  │
│ (Nginx)  │    │ (Node.js)  │    │ (15-alpine)  │
│  :3000   │    │  :5001     │    │   :5432&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



</description>
      <category>terraform</category>
      <category>aws</category>
      <category>infrastructure</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building a Production Grade AWS Infrastructure Project (Part 2): Containerization</title>
      <dc:creator>KithupaG</dc:creator>
      <pubDate>Fri, 10 Jul 2026 16:53:22 +0000</pubDate>
      <link>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-2-containerization-pkg</link>
      <guid>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-2-containerization-pkg</guid>
      <description>&lt;h2&gt;
  
  
  The Pivot: Why Containerization First?
&lt;/h2&gt;

&lt;p&gt;After careful thought and consideration, I realized that building the VPC right away wasn't the most efficient path. Instead, I went back to the drawing board and decided to containerize the application first. This ensures that the environment is consistent before we start orchestrating it in the cloud.&lt;/p&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-1-5ceb"&gt;previous post&lt;/a&gt;, I introduced the project, the file structure, and the "why" behind this build. In this post, I'll be walking through the containerization process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Strategy
&lt;/h2&gt;

&lt;p&gt;Since the frontend and backend are two distinct applications with different tech stacks, I wrote two separate &lt;code&gt;Dockerfiles&lt;/code&gt; that align with the project structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Backend (Node.js)
&lt;/h3&gt;

&lt;p&gt;For the backend, the setup is relatively straightforward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /usr/src/backend/&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 5000&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Breaking it down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base Image:&lt;/strong&gt; I used &lt;code&gt;node:22-slim&lt;/code&gt; instead of the common alpine version. This is because the backend uses &lt;code&gt;sqlite3&lt;/code&gt;, which requires dependencies like &lt;code&gt;gcc&lt;/code&gt;, &lt;code&gt;g++&lt;/code&gt;, and &lt;code&gt;python&lt;/code&gt; to compile. The slim image handles these better than the ultra-minimal Alpine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependencies:&lt;/strong&gt; I used &lt;code&gt;npm ci --omit=dev&lt;/code&gt;. This ignores development dependencies, keeping the production image as light as possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution:&lt;/strong&gt; We expose port 5000 and run the app using &lt;code&gt;node server.js&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  2. The Frontend (React + Nginx)
&lt;/h3&gt;

&lt;p&gt;The frontend utilizes a multi-stage build. This allows us to build the app in a Node environment and then serve the static files using Nginx.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 1: Build&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /usr/src/frontend/&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="c"&gt;# Stage 2: Serve&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;nginx:alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;server&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /usr/src/frontend/dist/ /usr/share/nginx/html&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; nginx.conf /etc/nginx/conf.d/default.conf&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 80&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["nginx", "-g", "daemon off;"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Why this approach?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lightweight:&lt;/strong&gt; Nginx is significantly faster and smaller than keeping a Node server running just to serve static React files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse Proxy:&lt;/strong&gt; Since React is client-side, it doesn't "know" how to route API requests to a backend in a different container. We use Nginx to handle these routes via a custom &lt;code&gt;nginx.conf&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD Friendly:&lt;/strong&gt; Building inside Docker means your CI/CD runner (like GitHub Actions) doesn't need to have Node installed. Everything happens inside the container.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Orchestrating with Docker Compose
&lt;/h2&gt;

&lt;p&gt;To manage the frontend, backend, and database simultaneously, I used Docker Compose.&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;note-taker&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./src/backend&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;5001:5000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
      &lt;span class="na"&gt;PGHOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
      &lt;span class="na"&gt;PGUSER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;PGDATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;notetaker&lt;/span&gt;
      &lt;span class="na"&gt;PGPORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
      &lt;span class="na"&gt;PGSSL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app-network&lt;/span&gt;

  &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15-alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production_db&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;notetaker&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;5432:5432"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db-data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app-network&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;notetaker"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;

  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./src/frontend&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;3000:80&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app-network&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app-network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Key Features of this Compose file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Health Checks:&lt;/strong&gt; The backend waits for the database to be "healthy" (using &lt;code&gt;pg_isready&lt;/code&gt;) before starting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port Mapping:&lt;/strong&gt; I mapped the backend to 5001 and the frontend to 3000 to avoid conflicts with other local services like XAMPP or Docker Desktop defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking:&lt;/strong&gt; All services share &lt;code&gt;app-network&lt;/code&gt;, allowing them to communicate via service names (e.g., the backend connects to &lt;code&gt;database:5432&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Nginx Configuration
&lt;/h2&gt;

&lt;p&gt;Without a custom nginx config, the frontend container would serve static files but have no idea where to send API requests. The browser makes requests to &lt;code&gt;/api/notes&lt;/code&gt; and &lt;code&gt;/health&lt;/code&gt;, but nginx doesn't know these belong to the backend.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend:5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/health&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend:5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/usr/share/nginx/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.html&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 key pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;proxy_pass&lt;/code&gt;&lt;/strong&gt; forwards &lt;code&gt;/api/&lt;/code&gt; and &lt;code&gt;/health&lt;/code&gt; requests to the backend container using its service name (&lt;code&gt;backend:5000&lt;/code&gt;). Docker's built-in DNS resolves the name across containers on the same network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;try_files&lt;/code&gt;&lt;/strong&gt; handles React's client-side routing. Without it, refreshing on a route like &lt;code&gt;/notes&lt;/code&gt; would return a 404 because nginx would look for a file that doesn't exist. This directive falls back to &lt;code&gt;index.html&lt;/code&gt; and lets React handle the route.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Troubleshooting: The SSL Headache
&lt;/h2&gt;

&lt;p&gt;While running the backend, I hit a snag. The logs showed a connection failure:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Failed to connect to PostgreSQL database: The server does not support SSL connections"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  The Cause
&lt;/h3&gt;

&lt;p&gt;In my application code, I had logic that forced SSL if the environment was set to production:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PGSSL&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Since the Docker Compose environment was set to &lt;code&gt;NODE_ENV: production&lt;/code&gt;, the app was refusing any non-SSL connection from the Postgres container.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;I updated the logic in &lt;code&gt;db.js&lt;/code&gt; to be more granular. This allows me to explicitly toggle SSL off via environment variables even in production-mode containers:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PGSSL&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rejectUnauthorized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PGSSL&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rejectUnauthorized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;By setting &lt;code&gt;PGSSL: false&lt;/code&gt; in the Docker Compose file, the connection was finally successful.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;Now that the application is fully containerized and running locally, the next step is to setup the IAM roles through a terraform script.&lt;/p&gt;



&lt;p&gt;Have you ever run into issues where "Production" environment flags caused local Docker testing to fail? How do you handle your SSL logic in dev vs. prod? Let me know in the comments!&lt;/p&gt;

&lt;p&gt;If you want to follow along on this project, here is my github repo&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KithupaG" rel="noopener noreferrer"&gt;
        KithupaG
      &lt;/a&gt; / &lt;a href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;
        aws-production-infra
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A complete AWS themed production infrastructure
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;aws-production-infra&lt;/h1&gt;

&lt;/div&gt;

&lt;/div&gt;
&lt;br&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


</description>
      <category>docker</category>
      <category>webdev</category>
      <category>aws</category>
      <category>node</category>
    </item>
    <item>
      <title>Building a Production Grade AWS Infrastructure Project (Part 1)</title>
      <dc:creator>KithupaG</dc:creator>
      <pubDate>Wed, 20 May 2026 15:07:03 +0000</pubDate>
      <link>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-1-5ceb</link>
      <guid>https://dev.to/trenation/building-a-production-grade-aws-infrastructure-project-part-1-5ceb</guid>
      <description>&lt;p&gt;I'm 16, self-taught, and currently studying for my AWS Solutions Architect Associate certification. Alongside the course, I'm building a production-grade AWS infrastructure from scrach and documenting the entire journey here&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Certs alone don't prove you can build anything. I want something real to show for it, So writing infrastruction that reflects how things are done in production and not just dwelling inside tutorial hell. Writing about it publicly keeps me accountable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is my stack?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloud: AWS&lt;/li&gt;
&lt;li&gt;Infrastructure as Code: Terraform (I decided to keep it modular with seperate dev and prod environments)&lt;/li&gt;
&lt;li&gt;CI/CD : Github Actions&lt;/li&gt;
&lt;li&gt;Containerization: Docker + Trivy (for container scanning)&lt;/li&gt;
&lt;li&gt;Application: A minimal PERN stack notes app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Heres what my folder structure looks like&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws-production-infra/&lt;br&gt;
├── modules/ # reusable Terraform modules&lt;br&gt;
│ ├── vpc/&lt;br&gt;
│ │ ├── main.tf&lt;br&gt;
│ │ ├── variables.tf&lt;br&gt;
│ │ ├── outputs.tf&lt;br&gt;
│ │ └── README.md&lt;br&gt;
│ ├── ec2-asg/&lt;br&gt;
│ │ ├── main.tf&lt;br&gt;
│ │ ├── variables.tf&lt;br&gt;
│ │ └── outputs.tf&lt;br&gt;
│ ├── alb/&lt;br&gt;
│ ├── rds/&lt;br&gt;
│ ├── s3-cdn/&lt;br&gt;
│ ├── iam/&lt;br&gt;
│ ├── security/&lt;br&gt;
│ └── monitoring/&lt;br&gt;
├── environments/&lt;br&gt;
│ ├── dev/&lt;br&gt;
│ │ ├── main.tf&lt;br&gt;
│ │ ├── variables.tf&lt;br&gt;
│ │ ├── terraform.tfvars&lt;br&gt;
│ │ └── backend.tf&lt;br&gt;
│ └── prod/&lt;br&gt;
├── app/&lt;br&gt;
│ ├── Dockerfile&lt;br&gt;
│ └── src/&lt;br&gt;
├── .github/&lt;br&gt;
│ └── workflows/&lt;br&gt;
│ ├── terraform-plan.yml&lt;br&gt;
│ └── docker-build.yml&lt;br&gt;
├── docs/&lt;br&gt;
│ ├── architecture.svg&lt;br&gt;
│ └── cost-estimate.md&lt;br&gt;
├── scripts/&lt;br&gt;
│ └── destroy-expensive.sh&lt;br&gt;
├── .gitignore&lt;br&gt;
└── README.md&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;They key decision I did here is seperating the &lt;code&gt;modules/&lt;/code&gt; folder from &lt;code&gt;environments/&lt;/code&gt;. Both environments call the same modules but with different variable values. Dev runs cheaper, single AZ resources purely for developing purposes while Prod runs Multi-AZ, larger instances in a more contained and secure setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I've done so far&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The folder structure is set up and the application code is in the repo. I vibe-coded the entire PERN app to save time, and honestly the infrastructure is what its all about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My build order is&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;- Write all the terraform infrastructure first.&lt;/li&gt;
&lt;li&gt;- Write the Dockerfile and get Trivy scanning running in CI&lt;/li&gt;
&lt;li&gt;- Wire everything together with the GitHub actions deployment pipeline&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next post will cover building the VPC modules (building subnets, route tables, NAT gateway, and flow logs)&lt;/p&gt;

&lt;p&gt;Repo is here if you want to follow along: &lt;a href="https://github.com/KithupaG/aws-production-infra" rel="noopener noreferrer"&gt;https://github.com/KithupaG/aws-production-infra&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>learning</category>
      <category>aws</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Building a Simple Server Monitoring Script in My Homelab</title>
      <dc:creator>KithupaG</dc:creator>
      <pubDate>Wed, 27 Aug 2025 16:47:01 +0000</pubDate>
      <link>https://dev.to/trenation/building-a-simple-server-monitoring-script-in-my-homelab-4d97</link>
      <guid>https://dev.to/trenation/building-a-simple-server-monitoring-script-in-my-homelab-4d97</guid>
      <description>&lt;p&gt;*&lt;em&gt;Why monitoring matters *&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Monitoring matters because it provides a shared feedback loop, necessary visibility in the system metrics, performance, security, it also enables rapid issue detection, and continous improvement&lt;/p&gt;

&lt;p&gt;See my code in my github here:- &lt;a href="https://github.com/KithupaG/Homelab/blob/main/monitoring/server-stats.sh" rel="noopener noreferrer"&gt;https://github.com/KithupaG/Homelab/blob/main/monitoring/server-stats.sh&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I built it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used functions to house the processes, adhering to the laws of writing code, inside those functions I housed the shell commands such as &lt;code&gt;top&lt;/code&gt;, &lt;code&gt;free&lt;/code&gt;, &lt;code&gt;df&lt;/code&gt;, &lt;code&gt;ps&lt;/code&gt; etc to fetch the data and used &lt;code&gt;awk&lt;/code&gt; to tabularize the data.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Demo output.&lt;/li&gt;
&lt;/ol&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%2Fua3ojsivqlmrd2o6yat7.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%2Fua3ojsivqlmrd2o6yat7.png" alt=" " width="533" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Future plan&lt;/li&gt;
&lt;li&gt;Add &lt;strong&gt;logging to a file&lt;/strong&gt; → &lt;code&gt;server_stats.log&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;Output as &lt;strong&gt;JSON&lt;/strong&gt; so it integrates with Prometheus/ELK.
&lt;/li&gt;
&lt;li&gt;Connect to &lt;strong&gt;Grafana&lt;/strong&gt; and make dashboards.
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>devops</category>
      <category>beginners</category>
      <category>programming</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
