Aws

Security Groups and NACLs: Network Security

Design secure AWS network architectures with security groups and NACLs for multi-tier Node.js applications

Security Groups and NACLs: Network Security

Every packet that enters or leaves your AWS VPC passes through two distinct layers of network security: security groups and network access control lists (NACLs). Understanding how these two mechanisms differ, when to use each, and how they interact is fundamental to building secure multi-tier architectures. If you get this wrong, you either leave your infrastructure wide open or spend hours debugging connectivity issues that turn out to be a missing egress rule.

Prerequisites

Before diving in, you should have:

  • An AWS account with VPC, EC2, and IAM permissions
  • Node.js v16 or later installed
  • The AWS SDK for JavaScript v3 (@aws-sdk/client-ec2)
  • Basic understanding of TCP/IP, ports, and CIDR notation
  • Familiarity with VPC concepts (subnets, route tables, internet gateways)

Install the SDK dependency:

npm install @aws-sdk/client-ec2

Security Groups vs NACLs: The Fundamental Difference

The single most important thing to understand is that security groups are stateful and NACLs are stateless. That one distinction drives almost every design decision you will make.

Security groups operate at the ENI (elastic network interface) level. They are attached to instances, load balancers, RDS databases, and Lambda functions running inside a VPC. When you allow inbound traffic on port 443, the return traffic is automatically allowed regardless of your outbound rules. Security groups evaluate all rules before making a decision — there is no rule ordering.

NACLs operate at the subnet level. Every subnet in your VPC has exactly one NACL associated with it. NACLs are stateless, meaning you must explicitly allow both inbound and outbound traffic for a connection to work. Rules are evaluated in order by rule number, and the first match wins.

Here is a side-by-side comparison:

Feature Security Groups NACLs
Scope ENI (instance-level) Subnet-level
State Stateful Stateless
Rule type Allow only Allow and Deny
Rule evaluation All rules evaluated First match wins (by rule number)
Default behavior Deny all inbound, allow all outbound Allow all inbound and outbound
Applied to Specific resources All traffic in the subnet
Return traffic Automatic Must be explicitly allowed

Stateful vs Stateless: Why It Matters

With a stateful firewall (security group), the system tracks connection state. When a client sends a SYN packet to your Node.js server on port 3000 and you have an inbound rule allowing port 3000, the security group remembers that connection. The response packets flowing back to the client on the ephemeral port are automatically permitted even if you have no explicit outbound rule for that port range.

With a stateless firewall (NACL), every packet is evaluated independently. The NACL does not know or care that a packet is part of an existing connection. If a client connects to port 3000 on your server, you need:

  1. An inbound rule allowing traffic on port 3000
  2. An outbound rule allowing traffic on ephemeral ports (1024-65535) back to the client

Forget that outbound rule on the NACL and your application silently drops responses. No error messages, no logs — just timeouts on the client side. I have watched engineers spend hours debugging application code when the problem was a missing NACL egress rule.

// Demonstrating why NACL requires both directions
// This is conceptual — showing what the NACL must allow

var inboundRule = {
    ruleNumber: 100,
    protocol: "tcp",
    ruleAction: "allow",
    cidrBlock: "0.0.0.0/0",
    portRange: { from: 3000, to: 3000 }  // Allow inbound to Node.js
};

var outboundRule = {
    ruleNumber: 100,
    protocol: "tcp",
    ruleAction: "allow",
    cidrBlock: "0.0.0.0/0",
    portRange: { from: 1024, to: 65535 }  // Allow return traffic on ephemeral ports
};

Inbound and Outbound Rule Configuration

Security Group Rules

Security group rules specify a protocol, port range, and source (for inbound) or destination (for outbound). Sources and destinations can be CIDR blocks, individual IP addresses, or — and this is powerful — other security group IDs.

var AWS = require("@aws-sdk/client-ec2");

var ec2 = new AWS.EC2Client({ region: "us-east-1" });

// Allow inbound HTTPS from anywhere
var httpsInbound = {
    GroupId: "sg-0abc123def456789",
    IpPermissions: [
        {
            IpProtocol: "tcp",
            FromPort: 443,
            ToPort: 443,
            IpRanges: [
                { CidrIp: "0.0.0.0/0", Description: "HTTPS from anywhere" }
            ]
        }
    ]
};

ec2.send(new AWS.AuthorizeSecurityGroupIngressCommand(httpsInbound))
    .then(function(result) {
        console.log("Inbound HTTPS rule added");
    })
    .catch(function(err) {
        console.error("Failed to add rule:", err.message);
    });

NACL Rules

NACL rules include a rule number that determines evaluation order. Lower numbers are evaluated first. AWS recommends incrementing by 100 (100, 200, 300) to leave room for inserting rules later.

var AWS = require("@aws-sdk/client-ec2");

var ec2 = new AWS.EC2Client({ region: "us-east-1" });

// Add inbound rule to NACL allowing HTTPS
var naclInbound = {
    NetworkAclId: "acl-0abc123def456789",
    RuleNumber: 100,
    Protocol: "6",        // TCP
    RuleAction: "allow",
    CidrBlock: "0.0.0.0/0",
    Egress: false,
    PortRange: { From: 443, To: 443 }
};

ec2.send(new AWS.CreateNetworkAclEntryCommand(naclInbound))
    .then(function(result) {
        console.log("NACL inbound rule added");
    })
    .catch(function(err) {
        console.error("Failed to add NACL rule:", err.message);
    });

// Critical: add the corresponding outbound rule for ephemeral ports
var naclOutbound = {
    NetworkAclId: "acl-0abc123def456789",
    RuleNumber: 100,
    Protocol: "6",
    RuleAction: "allow",
    CidrBlock: "0.0.0.0/0",
    Egress: true,
    PortRange: { From: 1024, To: 65535 }
};

ec2.send(new AWS.CreateNetworkAclEntryCommand(naclOutbound))
    .then(function(result) {
        console.log("NACL outbound ephemeral rule added");
    })
    .catch(function(err) {
        console.error("Failed to add NACL outbound rule:", err.message);
    });

Security Group Chaining for Multi-Tier Architectures

Security group chaining is one of the most underused features in AWS networking. Instead of referencing CIDR blocks, you reference another security group as the traffic source. This means traffic is only allowed from instances that belong to that specific security group, regardless of their IP addresses.

For a three-tier architecture (load balancer, application, database), the chain looks like this:

  • ALB security group: allows inbound 80/443 from the internet
  • Application security group: allows inbound 3000 only from the ALB security group
  • Database security group: allows inbound 5432 only from the application security group

This is powerful because if an attacker compromises an instance that is not in the application security group, they cannot reach the database tier even if they are on the same subnet.

var AWS = require("@aws-sdk/client-ec2");

var ec2 = new AWS.EC2Client({ region: "us-east-1" });

function createChainedSecurityGroups(vpcId) {
    var albSgId, appSgId, dbSgId;

    // Step 1: Create all three security groups
    return ec2.send(new AWS.CreateSecurityGroupCommand({
        GroupName: "alb-sg",
        Description: "ALB security group - accepts public traffic",
        VpcId: vpcId
    }))
    .then(function(result) {
        albSgId = result.GroupId;
        console.log("ALB SG created:", albSgId);

        return ec2.send(new AWS.CreateSecurityGroupCommand({
            GroupName: "app-sg",
            Description: "Application tier - accepts traffic from ALB only",
            VpcId: vpcId
        }));
    })
    .then(function(result) {
        appSgId = result.GroupId;
        console.log("App SG created:", appSgId);

        return ec2.send(new AWS.CreateSecurityGroupCommand({
            GroupName: "db-sg",
            Description: "Database tier - accepts traffic from app tier only",
            VpcId: vpcId
        }));
    })
    .then(function(result) {
        dbSgId = result.GroupId;
        console.log("DB SG created:", dbSgId);

        // Step 2: Configure ALB SG - allow HTTP/HTTPS from internet
        return ec2.send(new AWS.AuthorizeSecurityGroupIngressCommand({
            GroupId: albSgId,
            IpPermissions: [
                {
                    IpProtocol: "tcp",
                    FromPort: 80,
                    ToPort: 80,
                    IpRanges: [{ CidrIp: "0.0.0.0/0", Description: "HTTP from internet" }]
                },
                {
                    IpProtocol: "tcp",
                    FromPort: 443,
                    ToPort: 443,
                    IpRanges: [{ CidrIp: "0.0.0.0/0", Description: "HTTPS from internet" }]
                }
            ]
        }));
    })
    .then(function() {
        // Step 3: Configure App SG - allow port 3000 from ALB SG only
        return ec2.send(new AWS.AuthorizeSecurityGroupIngressCommand({
            GroupId: appSgId,
            IpPermissions: [
                {
                    IpProtocol: "tcp",
                    FromPort: 3000,
                    ToPort: 3000,
                    UserIdGroupPairs: [
                        { GroupId: albSgId, Description: "Node.js traffic from ALB" }
                    ]
                }
            ]
        }));
    })
    .then(function() {
        // Step 4: Configure DB SG - allow PostgreSQL from App SG only
        return ec2.send(new AWS.AuthorizeSecurityGroupIngressCommand({
            GroupId: dbSgId,
            IpPermissions: [
                {
                    IpProtocol: "tcp",
                    FromPort: 5432,
                    ToPort: 5432,
                    UserIdGroupPairs: [
                        { GroupId: appSgId, Description: "PostgreSQL from app tier" }
                    ]
                }
            ]
        }));
    })
    .then(function() {
        console.log("Security group chain configured successfully");
        return { albSgId: albSgId, appSgId: appSgId, dbSgId: dbSgId };
    });
}

createChainedSecurityGroups("vpc-0abc123def456789")
    .then(function(sgIds) {
        console.log("Security group IDs:", JSON.stringify(sgIds, null, 2));
    })
    .catch(function(err) {
        console.error("Error creating security group chain:", err.message);
    });

NACL Rule Ordering and Evaluation

NACL rules are evaluated in ascending order by rule number. The moment a rule matches, that rule's action (allow or deny) is applied, and no further rules are checked. Every NACL has an implicit deny-all rule with rule number * that catches anything not matched by an explicit rule.

This ordering gives you the ability to create deny rules, which security groups cannot do. A common pattern is to block known malicious IP ranges before allowing broader traffic:

function configureNaclWithDenyRules(naclId) {
    var ec2 = new AWS.EC2Client({ region: "us-east-1" });

    var rules = [
        // Rule 50: Block a known malicious CIDR range
        {
            NetworkAclId: naclId,
            RuleNumber: 50,
            Protocol: "-1",       // All protocols
            RuleAction: "deny",
            CidrBlock: "198.51.100.0/24",
            Egress: false
        },
        // Rule 100: Allow HTTPS inbound
        {
            NetworkAclId: naclId,
            RuleNumber: 100,
            Protocol: "6",
            RuleAction: "allow",
            CidrBlock: "0.0.0.0/0",
            Egress: false,
            PortRange: { From: 443, To: 443 }
        },
        // Rule 200: Allow SSH from corporate IP only
        {
            NetworkAclId: naclId,
            RuleNumber: 200,
            Protocol: "6",
            RuleAction: "allow",
            CidrBlock: "203.0.113.0/24",
            Egress: false,
            PortRange: { From: 22, To: 22 }
        },
        // Rule 300: Allow ephemeral ports inbound (for responses to outbound connections)
        {
            NetworkAclId: naclId,
            RuleNumber: 300,
            Protocol: "6",
            RuleAction: "allow",
            CidrBlock: "0.0.0.0/0",
            Egress: false,
            PortRange: { From: 1024, To: 65535 }
        },
        // Outbound: Allow all TCP outbound
        {
            NetworkAclId: naclId,
            RuleNumber: 100,
            Protocol: "6",
            RuleAction: "allow",
            CidrBlock: "0.0.0.0/0",
            Egress: true,
            PortRange: { From: 0, To: 65535 }
        }
    ];

    var promises = rules.map(function(rule) {
        return ec2.send(new AWS.CreateNetworkAclEntryCommand(rule));
    });

    return Promise.all(promises).then(function() {
        console.log("All NACL rules configured");
    });
}

The key insight: if 198.51.100.50 sends a request to port 443, rule 50 catches it first and denies it. The rule 100 allow on port 443 never gets evaluated. This is the only way to blacklist specific IPs in AWS network-level security.

Common Port Configurations for Node.js Applications

Here are the port configurations you will use repeatedly when running Node.js in AWS:

Service Port Protocol Notes
HTTP 80 TCP ALB listener, redirects to HTTPS
HTTPS 443 TCP ALB listener, terminates TLS
SSH 22 TCP EC2 admin access, restrict to known IPs
Node.js app 3000-9000 TCP Application port behind ALB
PostgreSQL 5432 TCP RDS, private subnets only
MongoDB 27017 TCP DocumentDB/self-hosted, private subnets only
Redis 6379 TCP ElastiCache, private subnets only
Ephemeral 1024-65535 TCP Return traffic (NACL outbound rules)

Never expose database ports to the internet. Not even temporarily. Not even "just for testing." Use an SSH tunnel or Systems Manager Session Manager for remote database access.

Prefix Lists and Managed Prefix Lists

Prefix lists let you group CIDR blocks into a reusable object that you can reference in security group and route table rules. AWS provides managed prefix lists for services like S3 and DynamoDB, and you can create custom prefix lists for your own CIDR ranges.

var AWS = require("@aws-sdk/client-ec2");

var ec2 = new AWS.EC2Client({ region: "us-east-1" });

// Create a custom prefix list for office IP ranges
function createOfficePrefixList() {
    return ec2.send(new AWS.CreateManagedPrefixListCommand({
        PrefixListName: "office-networks",
        MaxEntries: 10,
        AddressFamily: "IPv4",
        Entries: [
            { Cidr: "203.0.113.0/24", Description: "Main office" },
            { Cidr: "198.51.100.0/28", Description: "Remote office" },
            { Cidr: "192.0.2.64/26", Description: "VPN exit nodes" }
        ]
    }))
    .then(function(result) {
        var prefixListId = result.PrefixList.PrefixListId;
        console.log("Prefix list created:", prefixListId);

        // Use the prefix list in a security group rule
        return ec2.send(new AWS.AuthorizeSecurityGroupIngressCommand({
            GroupId: "sg-0abc123def456789",
            IpPermissions: [
                {
                    IpProtocol: "tcp",
                    FromPort: 22,
                    ToPort: 22,
                    PrefixListIds: [
                        { PrefixListId: prefixListId, Description: "SSH from offices" }
                    ]
                }
            ]
        }));
    })
    .then(function() {
        console.log("Security group rule with prefix list added");
    });
}

// Look up AWS-managed prefix list for S3
function getS3PrefixList() {
    return ec2.send(new AWS.DescribeManagedPrefixListsCommand({
        Filters: [
            { Name: "prefix-list-name", Values: ["com.amazonaws.us-east-1.s3"] }
        ]
    }))
    .then(function(result) {
        if (result.PrefixLists.length > 0) {
            console.log("S3 prefix list:", result.PrefixLists[0].PrefixListId);
            return result.PrefixLists[0].PrefixListId;
        }
        throw new Error("S3 prefix list not found");
    });
}

Prefix lists count toward your security group rules limit. Each prefix list entry counts as one rule. An office prefix list with 5 CIDR blocks referenced in one security group rule counts as 5 rules against your limit.

Security Group Limits and Optimization

Default limits you should know:

  • Security groups per VPC: 2,500
  • Inbound rules per security group: 60
  • Outbound rules per security group: 60
  • Security groups per ENI: 5

These are soft limits (you can request increases), but hitting them usually indicates a design problem. Here are optimization strategies:

Consolidate rules with port ranges. Instead of separate rules for ports 3000, 3001, 3002, use a single rule for port range 3000-3002.

Use prefix lists. Instead of 10 separate rules for 10 office IPs, create a prefix list and reference it once. But remember each entry still counts toward the rule limit.

Use security group references. One rule referencing a security group covers all instances in that group, regardless of how many there are.

Audit unused rules. VPC Flow Logs can help identify security group rules that never match any traffic. Remove dead rules to stay under limits.

// Check how many rules a security group has
function auditSecurityGroupRules(groupId) {
    var ec2 = new AWS.EC2Client({ region: "us-east-1" });

    return ec2.send(new AWS.DescribeSecurityGroupRulesCommand({
        Filters: [
            { Name: "group-id", Values: [groupId] }
        ]
    }))
    .then(function(result) {
        var rules = result.SecurityGroupRules;
        var inbound = rules.filter(function(r) { return !r.IsEgress; });
        var outbound = rules.filter(function(r) { return r.IsEgress; });

        console.log("Security Group:", groupId);
        console.log("  Inbound rules:", inbound.length, "/ 60");
        console.log("  Outbound rules:", outbound.length, "/ 60");

        if (inbound.length > 50 || outbound.length > 50) {
            console.warn("  WARNING: Approaching rule limit. Consider consolidation.");
        }

        return { inbound: inbound.length, outbound: outbound.length };
    });
}

VPC Design for Node.js Applications

A well-designed VPC for a Node.js application follows a three-tier subnet model across multiple availability zones:

VPC: 10.0.0.0/16
├── Public Subnets (ALB, NAT Gateways)
│   ├── 10.0.1.0/24  (us-east-1a)
│   └── 10.0.2.0/24  (us-east-1b)
├── Private Subnets (EC2/ECS/Lambda - Node.js app)
│   ├── 10.0.10.0/24 (us-east-1a)
│   └── 10.0.20.0/24 (us-east-1b)
└── Data Subnets (RDS, ElastiCache)
    ├── 10.0.100.0/24 (us-east-1a)
    └── 10.0.200.0/24 (us-east-1b)

Each tier gets its own NACL. The public NACL is the most permissive. The data NACL is the most restrictive — it only allows traffic from the private subnet CIDR ranges on specific database ports.

Complete Working Example: Three-Tier VPC with Security Groups and NACLs

This is a complete Node.js script that creates a production-ready three-tier VPC with properly configured security groups and NACLs. It handles the full lifecycle: VPC creation, subnet setup, security groups with chaining, and NACLs for each tier.

var AWS = require("@aws-sdk/client-ec2");

var ec2 = new AWS.EC2Client({ region: "us-east-1" });

var VPC_CIDR = "10.0.0.0/16";
var PUBLIC_SUBNET_A = "10.0.1.0/24";
var PUBLIC_SUBNET_B = "10.0.2.0/24";
var PRIVATE_SUBNET_A = "10.0.10.0/24";
var PRIVATE_SUBNET_B = "10.0.20.0/24";
var DATA_SUBNET_A = "10.0.100.0/24";
var DATA_SUBNET_B = "10.0.200.0/24";
var NODE_APP_PORT = 3000;

var resources = {};

function createVpc() {
    return ec2.send(new AWS.CreateVpcCommand({
        CidrBlock: VPC_CIDR,
        TagSpecifications: [{
            ResourceType: "vpc",
            Tags: [{ Key: "Name", Value: "nodejs-three-tier-vpc" }]
        }]
    }))
    .then(function(result) {
        resources.vpcId = result.Vpc.VpcId;
        console.log("VPC created:", resources.vpcId);

        // Enable DNS hostnames
        return ec2.send(new AWS.ModifyVpcAttributeCommand({
            VpcId: resources.vpcId,
            EnableDnsHostnames: { Value: true }
        }));
    });
}

function createSubnets() {
    var subnets = [
        { cidr: PUBLIC_SUBNET_A, az: "us-east-1a", name: "public-a" },
        { cidr: PUBLIC_SUBNET_B, az: "us-east-1b", name: "public-b" },
        { cidr: PRIVATE_SUBNET_A, az: "us-east-1a", name: "private-a" },
        { cidr: PRIVATE_SUBNET_B, az: "us-east-1b", name: "private-b" },
        { cidr: DATA_SUBNET_A, az: "us-east-1a", name: "data-a" },
        { cidr: DATA_SUBNET_B, az: "us-east-1b", name: "data-b" }
    ];

    var chain = Promise.resolve();
    resources.subnets = {};

    subnets.forEach(function(subnet) {
        chain = chain.then(function() {
            return ec2.send(new AWS.CreateSubnetCommand({
                VpcId: resources.vpcId,
                CidrBlock: subnet.cidr,
                AvailabilityZone: subnet.az,
                TagSpecifications: [{
                    ResourceType: "subnet",
                    Tags: [{ Key: "Name", Value: subnet.name }]
                }]
            }));
        })
        .then(function(result) {
            resources.subnets[subnet.name] = result.Subnet.SubnetId;
            console.log("Subnet created:", subnet.name, result.Subnet.SubnetId);
        });
    });

    return chain;
}

function createSecurityGroups() {
    // ALB Security Group
    return ec2.send(new AWS.CreateSecurityGroupCommand({
        GroupName: "three-tier-alb-sg",
        Description: "ALB - accepts HTTP/HTTPS from internet",
        VpcId: resources.vpcId
    }))
    .then(function(result) {
        resources.albSgId = result.GroupId;
        console.log("ALB SG:", resources.albSgId);

        // App Security Group
        return ec2.send(new AWS.CreateSecurityGroupCommand({
            GroupName: "three-tier-app-sg",
            Description: "App tier - accepts traffic from ALB",
            VpcId: resources.vpcId
        }));
    })
    .then(function(result) {
        resources.appSgId = result.GroupId;
        console.log("App SG:", resources.appSgId);

        // Database Security Group
        return ec2.send(new AWS.CreateSecurityGroupCommand({
            GroupName: "three-tier-db-sg",
            Description: "Database tier - accepts traffic from app tier",
            VpcId: resources.vpcId
        }));
    })
    .then(function(result) {
        resources.dbSgId = result.GroupId;
        console.log("DB SG:", resources.dbSgId);

        // Bastion Security Group (for SSH management)
        return ec2.send(new AWS.CreateSecurityGroupCommand({
            GroupName: "three-tier-bastion-sg",
            Description: "Bastion host - SSH from trusted IPs",
            VpcId: resources.vpcId
        }));
    })
    .then(function(result) {
        resources.bastionSgId = result.GroupId;
        console.log("Bastion SG:", resources.bastionSgId);
    });
}

function configureSecurityGroupRules() {
    var ruleConfigs = [
        // ALB: Allow HTTP and HTTPS from internet
        {
            GroupId: resources.albSgId,
            IpPermissions: [
                {
                    IpProtocol: "tcp",
                    FromPort: 80,
                    ToPort: 80,
                    IpRanges: [{ CidrIp: "0.0.0.0/0", Description: "HTTP" }]
                },
                {
                    IpProtocol: "tcp",
                    FromPort: 443,
                    ToPort: 443,
                    IpRanges: [{ CidrIp: "0.0.0.0/0", Description: "HTTPS" }]
                }
            ]
        },
        // App: Allow Node.js port from ALB SG and SSH from Bastion SG
        {
            GroupId: resources.appSgId,
            IpPermissions: [
                {
                    IpProtocol: "tcp",
                    FromPort: NODE_APP_PORT,
                    ToPort: NODE_APP_PORT,
                    UserIdGroupPairs: [
                        { GroupId: resources.albSgId, Description: "Node.js from ALB" }
                    ]
                },
                {
                    IpProtocol: "tcp",
                    FromPort: 22,
                    ToPort: 22,
                    UserIdGroupPairs: [
                        { GroupId: resources.bastionSgId, Description: "SSH from bastion" }
                    ]
                }
            ]
        },
        // Database: Allow PostgreSQL from App SG only
        {
            GroupId: resources.dbSgId,
            IpPermissions: [
                {
                    IpProtocol: "tcp",
                    FromPort: 5432,
                    ToPort: 5432,
                    UserIdGroupPairs: [
                        { GroupId: resources.appSgId, Description: "PostgreSQL from app tier" }
                    ]
                }
            ]
        },
        // Bastion: Allow SSH from a specific corporate CIDR
        {
            GroupId: resources.bastionSgId,
            IpPermissions: [
                {
                    IpProtocol: "tcp",
                    FromPort: 22,
                    ToPort: 22,
                    IpRanges: [
                        { CidrIp: "203.0.113.0/24", Description: "Corporate network SSH" }
                    ]
                }
            ]
        }
    ];

    var promises = ruleConfigs.map(function(config) {
        return ec2.send(new AWS.AuthorizeSecurityGroupIngressCommand(config));
    });

    return Promise.all(promises).then(function() {
        console.log("All security group rules configured");
    });
}

function createAndConfigureNacls() {
    // Create NACLs for each tier
    return ec2.send(new AWS.CreateNetworkAclCommand({
        VpcId: resources.vpcId,
        TagSpecifications: [{
            ResourceType: "network-acl",
            Tags: [{ Key: "Name", Value: "public-nacl" }]
        }]
    }))
    .then(function(result) {
        resources.publicNaclId = result.NetworkAcl.NetworkAclId;
        console.log("Public NACL:", resources.publicNaclId);

        return ec2.send(new AWS.CreateNetworkAclCommand({
            VpcId: resources.vpcId,
            TagSpecifications: [{
                ResourceType: "network-acl",
                Tags: [{ Key: "Name", Value: "private-nacl" }]
            }]
        }));
    })
    .then(function(result) {
        resources.privateNaclId = result.NetworkAcl.NetworkAclId;
        console.log("Private NACL:", resources.privateNaclId);

        return ec2.send(new AWS.CreateNetworkAclCommand({
            VpcId: resources.vpcId,
            TagSpecifications: [{
                ResourceType: "network-acl",
                Tags: [{ Key: "Name", Value: "data-nacl" }]
            }]
        }));
    })
    .then(function(result) {
        resources.dataNaclId = result.NetworkAcl.NetworkAclId;
        console.log("Data NACL:", resources.dataNaclId);

        // Configure public NACL rules
        var publicRules = [
            // Inbound: Allow HTTP
            { NetworkAclId: resources.publicNaclId, RuleNumber: 100, Protocol: "6",
              RuleAction: "allow", CidrBlock: "0.0.0.0/0", Egress: false,
              PortRange: { From: 80, To: 80 } },
            // Inbound: Allow HTTPS
            { NetworkAclId: resources.publicNaclId, RuleNumber: 200, Protocol: "6",
              RuleAction: "allow", CidrBlock: "0.0.0.0/0", Egress: false,
              PortRange: { From: 443, To: 443 } },
            // Inbound: Allow ephemeral ports (return traffic)
            { NetworkAclId: resources.publicNaclId, RuleNumber: 300, Protocol: "6",
              RuleAction: "allow", CidrBlock: "0.0.0.0/0", Egress: false,
              PortRange: { From: 1024, To: 65535 } },
            // Inbound: Allow SSH from corporate
            { NetworkAclId: resources.publicNaclId, RuleNumber: 400, Protocol: "6",
              RuleAction: "allow", CidrBlock: "203.0.113.0/24", Egress: false,
              PortRange: { From: 22, To: 22 } },
            // Outbound: Allow all TCP
            { NetworkAclId: resources.publicNaclId, RuleNumber: 100, Protocol: "6",
              RuleAction: "allow", CidrBlock: "0.0.0.0/0", Egress: true,
              PortRange: { From: 0, To: 65535 } }
        ];

        // Configure private NACL rules
        var privateRules = [
            // Inbound: Allow Node.js port from public subnets
            { NetworkAclId: resources.privateNaclId, RuleNumber: 100, Protocol: "6",
              RuleAction: "allow", CidrBlock: PUBLIC_SUBNET_A, Egress: false,
              PortRange: { From: NODE_APP_PORT, To: NODE_APP_PORT } },
            { NetworkAclId: resources.privateNaclId, RuleNumber: 110, Protocol: "6",
              RuleAction: "allow", CidrBlock: PUBLIC_SUBNET_B, Egress: false,
              PortRange: { From: NODE_APP_PORT, To: NODE_APP_PORT } },
            // Inbound: Allow SSH from public subnets (bastion)
            { NetworkAclId: resources.privateNaclId, RuleNumber: 200, Protocol: "6",
              RuleAction: "allow", CidrBlock: PUBLIC_SUBNET_A, Egress: false,
              PortRange: { From: 22, To: 22 } },
            { NetworkAclId: resources.privateNaclId, RuleNumber: 210, Protocol: "6",
              RuleAction: "allow", CidrBlock: PUBLIC_SUBNET_B, Egress: false,
              PortRange: { From: 22, To: 22 } },
            // Inbound: Ephemeral ports (return traffic from internet via NAT)
            { NetworkAclId: resources.privateNaclId, RuleNumber: 300, Protocol: "6",
              RuleAction: "allow", CidrBlock: "0.0.0.0/0", Egress: false,
              PortRange: { From: 1024, To: 65535 } },
            // Outbound: Allow all TCP (for NAT gateway, database access)
            { NetworkAclId: resources.privateNaclId, RuleNumber: 100, Protocol: "6",
              RuleAction: "allow", CidrBlock: "0.0.0.0/0", Egress: true,
              PortRange: { From: 0, To: 65535 } }
        ];

        // Configure data NACL rules — most restrictive
        var dataRules = [
            // Inbound: PostgreSQL from private subnets only
            { NetworkAclId: resources.dataNaclId, RuleNumber: 100, Protocol: "6",
              RuleAction: "allow", CidrBlock: PRIVATE_SUBNET_A, Egress: false,
              PortRange: { From: 5432, To: 5432 } },
            { NetworkAclId: resources.dataNaclId, RuleNumber: 110, Protocol: "6",
              RuleAction: "allow", CidrBlock: PRIVATE_SUBNET_B, Egress: false,
              PortRange: { From: 5432, To: 5432 } },
            // Outbound: Ephemeral ports back to private subnets
            { NetworkAclId: resources.dataNaclId, RuleNumber: 100, Protocol: "6",
              RuleAction: "allow", CidrBlock: PRIVATE_SUBNET_A, Egress: true,
              PortRange: { From: 1024, To: 65535 } },
            { NetworkAclId: resources.dataNaclId, RuleNumber: 110, Protocol: "6",
              RuleAction: "allow", CidrBlock: PRIVATE_SUBNET_B, Egress: true,
              PortRange: { From: 1024, To: 65535 } }
        ];

        var allRules = publicRules.concat(privateRules).concat(dataRules);
        var rulePromises = allRules.map(function(rule) {
            return ec2.send(new AWS.CreateNetworkAclEntryCommand(rule));
        });

        return Promise.all(rulePromises);
    })
    .then(function() {
        // Associate NACLs with subnets
        var associations = [
            { naclId: resources.publicNaclId, subnetKey: "public-a" },
            { naclId: resources.publicNaclId, subnetKey: "public-b" },
            { naclId: resources.privateNaclId, subnetKey: "private-a" },
            { naclId: resources.privateNaclId, subnetKey: "private-b" },
            { naclId: resources.dataNaclId, subnetKey: "data-a" },
            { naclId: resources.dataNaclId, subnetKey: "data-b" }
        ];

        // To replace NACL association, we need to find the existing association ID
        // For new VPCs, all subnets use the default NACL
        return ec2.send(new AWS.DescribeNetworkAclsCommand({
            Filters: [{ Name: "vpc-id", Values: [resources.vpcId] }]
        }))
        .then(function(result) {
            var defaultNacl = result.NetworkAcls.find(function(nacl) {
                return nacl.IsDefault;
            });

            var associationPromises = associations.map(function(assoc) {
                var subnetId = resources.subnets[assoc.subnetKey];
                var existingAssoc = defaultNacl.Associations.find(function(a) {
                    return a.SubnetId === subnetId;
                });

                if (existingAssoc) {
                    return ec2.send(new AWS.ReplaceNetworkAclAssociationCommand({
                        AssociationId: existingAssoc.NetworkAclAssociationId,
                        NetworkAclId: assoc.naclId
                    }));
                }
                return Promise.resolve();
            });

            return Promise.all(associationPromises);
        });
    })
    .then(function() {
        console.log("All NACLs configured and associated with subnets");
    });
}

// Run the full setup
createVpc()
    .then(createSubnets)
    .then(createSecurityGroups)
    .then(configureSecurityGroupRules)
    .then(createAndConfigureNacls)
    .then(function() {
        console.log("\n--- Three-Tier VPC Setup Complete ---");
        console.log(JSON.stringify(resources, null, 2));
    })
    .catch(function(err) {
        console.error("Setup failed:", err.message);
        console.error("You may need to clean up partial resources manually");
        console.error("VPC ID:", resources.vpcId || "not created");
    });

Common Issues and Troubleshooting

1. Connection Timeout — Missing NACL Ephemeral Port Rule

Symptom: Your Node.js application receives requests but clients get connection timeouts. The application logs show successful processing.

Error: connect ETIMEDOUT 10.0.10.45:3000
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1141:16)

Cause: The NACL on the application subnet allows inbound traffic on port 3000 but does not have an outbound rule for ephemeral ports (1024-65535). Because NACLs are stateless, return traffic is blocked.

Fix: Add an outbound rule on the private subnet NACL allowing ephemeral ports:

ec2.send(new AWS.CreateNetworkAclEntryCommand({
    NetworkAclId: privateNaclId,
    RuleNumber: 200,
    Protocol: "6",
    RuleAction: "allow",
    CidrBlock: "0.0.0.0/0",
    Egress: true,
    PortRange: { From: 1024, To: 65535 }
}));

2. RDS Connection Refused — Security Group Not Referencing Correct Source

Symptom: Your Node.js app cannot connect to RDS even though the database security group "allows port 5432."

Error: connect ECONNREFUSED 10.0.100.23:5432
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1141:16)
code: 'ECONNREFUSED'

Cause: The RDS security group allows port 5432 from a CIDR block, but your EC2 instances got new IP addresses after a reboot or scaling event and are no longer in that CIDR range.

Fix: Use security group references instead of CIDR blocks. Reference the application security group ID so any instance attached to that group automatically has access regardless of its IP address.

3. Duplicate Rule Error When Updating NACLs

Symptom: Your deployment script fails when trying to update NACL rules.

InvalidParameterValue: The network acl entry with rule number 100 already exists

Cause: CreateNetworkAclEntry fails if a rule with that number already exists. Unlike security groups, you cannot simply re-apply NACL rules.

Fix: Use ReplaceNetworkAclEntry for updates, or delete then recreate:

function upsertNaclRule(params) {
    return ec2.send(new AWS.ReplaceNetworkAclEntryCommand(params))
        .catch(function(err) {
            if (err.Code === "InvalidParameterValue") {
                // Rule does not exist yet, create it
                return ec2.send(new AWS.CreateNetworkAclEntryCommand(params));
            }
            throw err;
        });
}

4. Security Group Rule Limit Exceeded

Symptom: Adding a new rule fails with a limit error.

RulesPerSecurityGroupLimitExceeded: The maximum number of rules per security group has been reached.

Cause: You have hit the 60 inbound or 60 outbound rules limit. This commonly happens when you add individual IP addresses instead of using CIDR ranges or prefix lists.

Fix: Consolidate rules. Replace individual /32 entries with broader CIDR blocks or create a managed prefix list. Audit rules with VPC Flow Logs to find unused rules you can remove.

5. Cross-AZ Traffic Blocked by Subnet-Specific NACL Rules

Symptom: Application instances in us-east-1a can reach the database, but instances in us-east-1b cannot.

Error: Connection timed out after 30000ms
    at Connection._handleTimeoutError (pg-connection/connection.js:87:26)

Cause: The data NACL only has an inbound rule for the private subnet in us-east-1a (10.0.10.0/24) but not for us-east-1b (10.0.20.0/24).

Fix: Add NACL rules for every private subnet CIDR that needs to reach the data tier. This is a common oversight when you add new availability zones.

Best Practices

  • Use security groups as your primary defense, NACLs as the secondary layer. Security groups are easier to manage due to statefulness and the ability to reference other security groups. Use NACLs for broad deny rules (blocking IP ranges, restricting protocols) and as a subnet-level safety net.

  • Never use 0.0.0.0/0 as a source for database or SSH security group rules. This is the single most common security mistake in AWS. Restrict SSH to known IP ranges or use SSM Session Manager to eliminate SSH entirely. Restrict database ports to the application security group only.

  • Always chain security groups in multi-tier architectures. Referencing security group IDs instead of CIDR blocks means your rules survive IP address changes, scaling events, and instance replacements. It is also self-documenting — you can see the traffic flow by reading the security group references.

  • Tag everything with purpose and owner. When you have 50 security groups in a VPC, names like sg-0abc123 tell you nothing. Use tags consistently: Name, Environment, Tier, ManagedBy. This pays for itself the first time you need to audit or troubleshoot.

  • Use VPC Flow Logs for visibility. You cannot troubleshoot what you cannot see. Enable VPC Flow Logs at the VPC level and send them to CloudWatch Logs or S3. Filter for REJECT actions to find traffic being blocked by security groups or NACLs.

  • Leave gaps in NACL rule numbers. Use increments of 100 (100, 200, 300). When you inevitably need to insert a deny rule before an allow rule, you will have room at rule numbers 50, 150, or 250 without renumbering everything.

  • Automate security group management in code. Do not click through the AWS console to create security rules for production. Use the AWS SDK, CloudFormation, or Terraform. Manual console changes drift, are not auditable, and cannot be reproduced in disaster recovery scenarios.

  • Regularly audit security group rules. Use DescribeSecurityGroupRules and DescribeStaleSecurityGroups to find rules referencing deleted security groups or overly broad permissions. AWS Config rules like restricted-ssh and vpc-sg-open-only-to-authorized-ports automate this check.

  • Consider AWS Network Firewall for advanced inspection. Security groups and NACLs operate at layers 3 and 4. If you need deep packet inspection, domain filtering, or IDS/IPS capabilities, AWS Network Firewall sits in your VPC and inspects traffic at layer 7.

References

Powered by Contentful