DEV Community

Cover image for Building a Production Grade AWS Infrastructure Project (Part 4): Security Groups Terraform
KithupaG
KithupaG

Posted on • Edited on

Building a Production Grade AWS Infrastructure Project (Part 4): Security Groups Terraform

Continuing the build

In my previous post, 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.

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.

If you want to follow along with the full series, here's the architecture plan:

VPC -> IAM -> Security Groups -> RDS -> ALB -> EC2 ASG -> S3+CDN -> Monitoring -> CI/CD

What is a Security Group?

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.

The key thing to understand: security groups are stateful. 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.

Starting from the Terraform template

I started by copying the AWS provider's example template:

resource "aws_security_group" "allow_tls" {
  name        = "allow_tls"
  description = "Allow TLS inbound traffic and all outbound traffic"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "allow_tls"
  }
}

resource "aws_vpc_security_group_ingress_rule" "allow_tls_ipv4" {
  security_group_id = aws_security_group.allow_tls.id
  cidr_ipv4         = aws_vpc.main.cidr_block
  from_port         = 443
  ip_protocol       = "tcp"
  to_port           = 443
}

resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_ipv4" {
  security_group_id = aws_security_group.allow_tls.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}
Enter fullscreen mode Exit fullscreen mode

There are three resources here:

  1. The security group itself — the container for the rules
  2. Ingress rule — controls what traffic can come in
  3. Egress rule — controls what traffic can go out

The template was a starting point. I needed to replace it with rules that actually match my application.

The Security Group Module

I created a dedicated modules/security/ directory with a main.tf file. The module takes three variables:

variable "vpc_id" {}
variable "cidr_cidr_block" {}
variable "environment" {}
Enter fullscreen mode Exit fullscreen mode
  • vpc_id — which VPC does this security group belong to?
  • cidr_cidr_block — the CIDR range of the VPC, used to allow internal traffic
  • environment — for tagging purposes (dev, prod, etc.)

Then the security group resource:

resource "aws_security_group" "ap_sg" {
  name        = "app_security_group"
  description = "Security group for the task manager application"
  vpc_id      = var.vpc_id

  tags = {
    Name        = "allow_tls_ssh"
    Environment = var.environment
  }
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here. The important part is that vpc_id 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.

Ingress Rules: What gets in

Now for the actual firewall rules. I created separate ingress resources for each port the application needs:

resource "aws_vpc_security_group_ingress_rule" "application_frontend" {
  security_group_id = aws_security_group.ap_sg.id
  cidr_ipv4         = var.cidr_cidr_block
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_ingress_rule" "allow_SSH" {
  security_group_id = aws_security_group.ap_sg.id
  cidr_ipv4         = var.cidr_cidr_block
  from_port         = 22
  ip_protocol       = "tcp"
  to_port           = 22
}

resource "aws_vpc_security_group_ingress_rule" "allow_backend_access" {
  security_group_id = aws_security_group.ap_sg.id
  cidr_ipv4         = var.cidr_cidr_block
  from_port         = 5000
  ip_protocol       = "tcp"
  to_port           = 5000
}

resource "aws_vpc_security_group_ingress_rule" "allow_database_access" {
  security_group_id = aws_security_group.ap_sg.id
  cidr_ipv4         = var.cidr_cidr_block
  from_port         = 5432
  ip_protocol       = "tcp"
  to_port           = 5432
}
Enter fullscreen mode Exit fullscreen mode

Breaking down each rule:

Port Purpose Why it's open
80 Frontend (HTTP) The React app served by Nginx
22 SSH For remote access and debugging
5000 Backend (Node.js) Frontend-to-backend communication
5432 PostgreSQL Backend-to-database communication

CIDR Blocks: Who can reach these ports?

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

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

If you wanted to allow traffic from anywhere on the internet, you'd use:

cidr_ipv4 = "0.0.0.0/0"
Enter fullscreen mode Exit fullscreen mode

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.

Egress Rules: What gets out

resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_ipv4" {
  security_group_id = aws_security_group.ap_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}
Enter fullscreen mode Exit fullscreen mode

I enabled all outbound traffic. The -1 protocol means "all protocols" — TCP, UDP, ICMP, everything.

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.

For most applications, allowing all outbound is the practical choice. You can always tighten it later if your threat model demands it.

Connecting the Module to the Environment

The security group module doesn't know about your VPC or environment variables until you wire them up. In the environment's main.tf:

module "dev_sg" {
  source = "../../modules/security"

  vpc_id          = module.dev_app_vpc.vpc_id
  cidr_cidr_block = var.vpc_cidr
  environment     = var.environment
}
Enter fullscreen mode Exit fullscreen mode

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.

The Outputs

Without an outputs file, the security group is trapped inside its module. No other module can reference it.

output "main_sg" {
  description = "Main sg for the application to access tls, ssh, and allow all outbound connections"
  value       = aws_security_group.ap_sg.id
}

output "vpc_cidr_output" {
  description = "Outputs cidr value for vpc"
  value       = aws_security_group.ap_sg.vpc_cidr_block
}
Enter fullscreen mode Exit fullscreen mode
  • main_sg — exports the security group ID so RDS, ALB, and ECS can reference it
  • vpc_cidr_output — exports the CIDR block for use in other networking rules

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.

What I learnt

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

Next up: The RDS module

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.

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!

Want to follow along? The full code is available here:

GitHub logo KithupaG / aws-production-infra

A complete AWS themed production infrastructure

CloudNotes - Production Grade AWS Infrastructure

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.

Architecture

                        ┌──────────────────────────────────┐
                        │         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

Top comments (0)