IBM Cloud VPC: Layering Network ACLs and Security Groups for Defense in Depth
Security Groups and Network ACLs in IBM Cloud VPC provide layered defense: SGs are stateful instance allow‑lists; ACLs are stateless subnet guardrails with deny rules. See a three‑tier example, CLI steps, Flow Log verification, and trade‑offs.
05 Jun 2026, 03:52 UTC

The problem: one layer leaves gaps
\nYou’ve deployed a three-tier application in an IBM Cloud VPC. The web tier sits in a public subnet, app and database tiers in private subnets. You’ve attached Security Groups (SGs) to each instance: web SG allows TCP 443 from anywhere, app SG allows TCP 8080 only from the web SG, database SG allows TCP 5432 only from the app SG. Traffic flows, tests pass. Then a misconfigured container in the web subnet starts scanning the app subnet on port 22. The app SG doesn’t have a rule for SSH, so the packets are dropped — good. But what if the app SG is accidentally edited to allow SSH from the web SG CIDR? The instance-level allow-list just widened. You need a second, independent layer that can deny traffic even if an SG rule is too permissive.
\nThat’s where Network ACLs (ACLs) come in. SGs are stateful allow-lists at the network interface level. ACLs are stateless, subnet-level rules that support both allow and deny, evaluated in priority order. Using both gives you defense in depth: SGs express application intent (who talks to whom), ACLs enforce subnet boundaries (what’s fundamentally permitted to enter or leave a subnet).
\n\nSecurity Groups: stateful, instance-scoped allow-lists
\nAn SG rule says “allow this protocol/port from this source.” The source can be a CIDR block or another SG in the same VPC (by ID). Because SGs are stateful, return traffic for an allowed connection is automatically permitted — you don’t write a separate outbound rule for ephemeral ports. The default SG in every VPC allows all inbound and outbound traffic between instances that share that SG. In production you replace it with explicit rules.
\nKey limits: up to 1,000 rules per SG. SG-to-SG references only work within the same VPC; cross-VPC traffic (via Transit Gateway) must use CIDRs. IPv6 requires separate rules (::/0) — IPv4 rules don’t implicitly cover IPv6.
\n\nNetwork ACLs: stateless, subnet-scoped guardrails
\nAn ACL is attached to a subnet. It contains inbound and outbound rule lists, each evaluated by priority (lowest number first). Rules can allow or deny. Because ACLs are stateless, you must write explicit inbound and outbound rules for return traffic — typically allowing ephemeral ports 1024–65535 back to the source CIDR. Forgetting the outbound rule is the most common cause of silent connectivity failures.
\nLimits: 200 inbound + 200 outbound rules per ACL. Rules only accept CIDR prefixes (no SG references). Priority numbers must be unique per direction; leave gaps (e.g., 100, 200) so you can insert rules later without renumbering. Changing an attached ACL’s rules takes effect immediately — no detach/reattach needed. Test in staging first; a misordered deny rule can cut off SSH or bastion access instantly.
\n\nWorked example: three-tier workload with both layers
\nAssume a VPC 10.0.0.0/16 with three subnets:
- \n
- Public subnet
10.0.1.0/24— web tier \n - Private subnet
10.0.2.0/24— app tier \n - Private subnet
10.0.3.0/24— database tier \n
Security Groups (application intent)
\n# Web SG: allow HTTPS from internet, allow SSH from bastion SG\nibmcloud is security-group-rule-create web-sg inbound tcp 443 443 0.0.0.0/0\nibmcloud is security-group-rule-create web-sg inbound tcp 22 22 --remote-sg bastion-sg\n\n# App SG: allow 8080 only from web SG\nibmcloud is security-group-rule-create app-sg inbound tcp 8080 8080 --remote-sg web-sg\n\n# DB SG: allow 5432 only from app SG\nibmcloud is security-group-rule-create db-sg inbound tcp 5432 5432 --remote-sg app-sg\n\nNo outbound SG rules needed — stateful return traffic is automatic.
\n\nNetwork ACLs (subnet boundaries)
\nAttach one ACL per subnet. Example for the database subnet (10.0.3.0/24):
# Create ACL\nibmcloud is network-acl-create db-acl my-vpc\n\n# Inbound: allow PostgreSQL from app subnet, deny everything else\nibmcloud is network-acl-rule-create db-acl inbound allow tcp 5432 5432 10.0.2.0/24 --priority 100\nibmcloud is network-acl-rule-create db-acl inbound deny all --priority 32767\n\n# Outbound: allow return traffic to app subnet ephemeral ports, deny everything else\nibmcloud is network-acl-rule-create db-acl outbound allow tcp 1024 65535 10.0.2.0/24 --priority 100\nibmcloud is network-acl-rule-create db-acl outbound deny all --priority 32767\n\n# Attach to subnet\nibmcloud is subnet-network-acl-attach db-subnet db-acl\n\nRepeat for app subnet (allow 8080 from web subnet inbound, allow ephemeral returns to web subnet outbound) and public subnet (allow 443/22 from internet inbound, allow ephemeral returns to internet outbound).
\n\nVerification with VPC Flow Logs
\nEnable Flow Logs to a Cloud Object Storage bucket or Logging instance:
\nibmcloud is flow-log-create my-flow-log my-vpc --target-type cos --target-cos-bucket my-bucket\n\nGenerate test traffic (e.g., curl https://web-app, ssh app-instance). Download the logs and inspect the action field (ACCEPT or REJECT) and the security_group_id / network_acl_id fields to confirm which layer decided. This is essential when troubleshooting combined SG+ACL deployments.
Trade-offs and limitations
\n- \n
- Rule duplication: You express similar intent twice (SG allow + ACL allow). Keep ACL rules coarse (subnet-to-subnet) and SG rules fine-grained (SG-to-SG) to minimize churn. \n
- Stateless complexity: Every new service port requires two ACL rules (inbound + outbound ephemeral). Automate with Terraform (
ibm_is_network_acl_rule) and validate priority gaps in CI. \n - Cross-VPC: SG references don’t cross VPCs. For Transit Gateway traffic, ACLs must use CIDRs. Plan CIDR allocations early to keep ACL rule counts low. \n
- Immediate effect: ACL rule changes are instant. For zero-downtime SG rolls, create a new SG, attach to new instances, then migrate — don’t edit live SG rules if you can’t tolerate a brief misconfiguration window. \n
- IPv6: Duplicate every ACL and SG rule for ::/0 if you enable IPv6. It’s not automatic. \n
Actionable closing: verify your layers today
\n- \n
- List every subnet’s attached ACL:
ibmcloud is subnet-network-acl. If none, you’re running with the default allow-all ACL. \n - For each production subnet, write a deny-all rule at priority 32767 (inbound and outbound) and explicit allow rules for required CIDR/port combinations at lower priorities. \n
- Enable VPC Flow Logs if not already on. Run a controlled test (SSH, HTTP, DB connection) and confirm logs show
ACCEPTat both SG and ACL layers. \n - Add a CI check:
terraform planmust show no priority conflicts inibm_is_network_acl_ruleresources before merge. \n
Two layers, two failure modes. The SG expresses who should talk; the ACL enforces who can reach the subnet at all. Together they turn a single misconfiguration into a contained incident instead of a breach.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.