Network Security for Cloud-Native Startups: VPCs, Security Groups, Zero Trust, and Practical Hardening for Production Infrastructure
A practical guide to network security for startup engineering teams on AWS and GCP: VPC design, security groups, NACLs, mTLS, zero trust, DNS security, egress filtering, and a hardening checklist with Terraform examples.
Most startup infrastructure gets incrementally networked: a VPC here, a security group there, a database opened to 0.0.0.0/0 temporarily during a deadline crunch that never gets fixed. The result is a production environment that looks defensible from the outside but has flat lateral movement paths internally. A compromised Lambda function can reach your RDS instance. A leaked API key grants access across environments. One misconfigured NACL and your private subnet is no longer private.
This guide covers the network security decisions that matter at the seed-to-Series-A stage, where you are small enough to make good architectural choices but large enough that those choices will be load-bearing for years.
VPC Design: What Actually Matters
A VPC is not a security control by itself. It is a container. What you do inside determines whether it provides meaningful isolation.
Subnet topology. The canonical pattern is three tiers: public (load balancers, NAT gateways, bastion hosts), private (application servers, containers, Lambda functions in a VPC), and isolated (databases, internal caches, anything that should never initiate outbound connections).
# terraform/modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr # e.g. 10.0.0.0/16
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.env}-vpc" }
}
# Public subnets: one per AZ, /24 each
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.env}-public-${count.index + 1}", Tier = "public" }
}
# Private subnets: /24 each, offset to avoid overlap
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = var.availability_zones[count.index]
tags = { Name = "${var.env}-private-${count.index + 1}", Tier = "private" }
}
# Isolated subnets: databases only
resource "aws_subnet" "isolated" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 20)
availability_zone = var.availability_zones[count.index]
tags = { Name = "${var.env}-isolated-${count.index + 1}", Tier = "isolated" }
}
The isolated subnet has no route to the internet gateway and no NAT gateway route. Its route table contains a single entry: local VPC traffic only. Databases here cannot initiate outbound connections under any circumstances.
NAT gateways. Private subnets need outbound internet access for package downloads, external API calls, and similar. NAT gateways handle this without exposing instances to inbound traffic. The tradeoff: one NAT gateway per AZ avoids cross-AZ data transfer charges but doubles or triples cost. For startups, one NAT gateway in a single AZ is acceptable until you are running production workloads that need HA at this layer.
resource "aws_eip" "nat" {
count = 1 # scale to length(var.availability_zones) for multi-AZ
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
count = 1
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "${var.env}-nat-${count.index + 1}" }
}
resource "aws_route_table" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[0].id
}
}
Security Groups and NACLs: Two Different Tools
Security groups are stateful firewalls attached to resources (instances, RDS, Lambda ENIs, ALBs). NACLs are stateless firewalls attached to subnets. They serve different purposes and most teams underuse NACLs.
Security groups: least privilege by default. The common mistake is a wide security group that allows all traffic within the VPC. Instead, chain groups explicitly.
# ALB: accepts 443 from the internet
resource "aws_security_group" "alb" {
name = "${var.env}-alb"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
}
# App servers: only accept traffic from the ALB security group
resource "aws_security_group" "app" {
name = "${var.env}-app"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.db.id]
}
}
# Database: only accepts traffic from the app security group
resource "aws_security_group" "db" {
name = "${var.env}-db"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
# no egress rule: RDS does not initiate outbound connections
}
Chaining security groups by ID rather than CIDR ranges means you do not need to know or maintain IP ranges. When you scale out, new instances in the app security group automatically get database access. No CIDR updates, no drift.
NACLs: block at the subnet boundary. NACLs add a stateless layer before traffic reaches any resource. Because they are stateless, you need explicit rules for return traffic (ephemeral ports 1024-65535). This makes them awkward for fine-grained control but useful for coarse subnet isolation.
A useful pattern: block the isolated subnet from receiving any inbound traffic except from private subnets, and block all outbound traffic from isolated subnets entirely at the NACL level as a belt-and-suspenders control.
resource "aws_network_acl" "isolated" {
vpc_id = aws_vpc.main.id
subnet_ids = aws_subnet.isolated[*].id
# Allow inbound from private subnets on DB port
ingress {
rule_no = 100
action = "allow"
protocol = "tcp"
from_port = 5432
to_port = 5432
cidr_block = var.private_subnet_cidr_range
}
# Allow return traffic (stateless: must explicitly allow ephemeral ports)
egress {
rule_no = 100
action = "allow"
protocol = "tcp"
from_port = 1024
to_port = 65535
cidr_block = var.private_subnet_cidr_range
}
# Deny everything else
ingress {
rule_no = 32766
action = "deny"
protocol = "-1"
from_port = 0
to_port = 0
cidr_block = "0.0.0.0/0"
}
egress {
rule_no = 32766
action = "deny"
protocol = "-1"
from_port = 0
to_port = 0
cidr_block = "0.0.0.0/0"
}
}
Zero Trust Networking Inside the VPC
Zero trust at the network layer means: being on the same VPC (or the same subnet) does not grant authorization to talk to a service. Every connection requires authentication and authorization, regardless of source IP.
For startup-scale deployments, zero trust translates to three concrete practices:
1. mTLS between services. Each service presents a client certificate when initiating connections. The server verifies it. No certificate, no connection. This means a compromised service cannot impersonate other services and cannot reach services that do not explicitly trust its certificate.
A lightweight way to implement mTLS without a full service mesh is to use a sidecar or library-level TLS with a short-lived certificate issuer. AWS Certificate Manager Private CA handles certificate issuance; your services load certificates at startup and periodically rotate them.
// src/lib/mtls-client.ts
import * as tls from "tls";
import * as fs from "fs";
interface MtlsConfig {
cert: string; // path to PEM certificate
key: string; // path to PEM private key
ca: string; // path to CA certificate bundle
serverName: string;
}
export function createMtlsAgent(config: MtlsConfig): tls.SecureContext {
return tls.createSecureContext({
cert: fs.readFileSync(config.cert),
key: fs.readFileSync(config.key),
ca: fs.readFileSync(config.ca),
// Reject connections where cert does not match expected CA
rejectUnauthorized: true,
});
}
// Usage with node-fetch or undici
import { Agent } from "undici";
export function buildMtlsHttpClient(config: MtlsConfig): Agent {
return new Agent({
connect: {
cert: fs.readFileSync(config.cert),
key: fs.readFileSync(config.key),
ca: fs.readFileSync(config.ca),
rejectUnauthorized: true,
servername: config.serverName,
},
});
}
For teams already running Kubernetes, Istio or Linkerd handle mTLS transparently at the sidecar layer, which removes per-service implementation burden. The tradeoff is operational complexity of the mesh itself. At fewer than five services, library-level mTLS is often simpler to reason about.
2. IAM-based authorization for AWS services. Rather than storing database credentials in environment variables, use IAM authentication for RDS and IAM roles for DynamoDB, SQS, S3, and similar. An EC2 instance or ECS task with the right IAM role can connect without a static password. Rotation is automatic. Blast radius of a compromised instance is bounded by its role policy.
3. Service accounts and workload identity. On GCP, Workload Identity Federation maps Kubernetes service accounts to GCP IAM service accounts. The workload exchanges a short-lived Kubernetes token for a short-lived GCP token. No long-lived credentials in environment variables.
Bastion Hosts vs. SSM Session Manager
The traditional bastion host (a jump server in a public subnet, accessible via SSH) has two problems: it is a persistent attack surface, and managing SSH keys at scale is painful. AWS Systems Manager Session Manager and GCP Identity-Aware Proxy (IAP) TCP tunneling solve both.
SSM Session Manager opens an authenticated, encrypted tunnel from your machine to a private instance without the instance having a public IP or any inbound security group rules. Authentication goes through IAM. Every session is logged to CloudWatch. There is no persistent listener on port 22.
# IAM policy for SSM session access (attach to engineer IAM role)
resource "aws_iam_policy" "ssm_session" {
name = "${var.env}-ssm-session-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ssm:StartSession",
"ssm:TerminateSession",
"ssm:ResumeSession",
"ssm:DescribeSessions",
"ssm:GetConnectionStatus",
]
Resource = "*"
Condition = {
StringEquals = {
"ssm:resourceTag/Env" = var.env
}
}
},
{
Effect = "Allow"
Action = ["ssm:DescribeInstanceInformation"]
Resource = "*"
}
]
})
}
# SSM agent requires this role on the target instance
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.instance.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
To start a session: aws ssm start-session --target i-0abc123def456. For port forwarding to RDS (which has no SSM agent itself), forward through a private instance: aws ssm start-session --target i-0abc123def456 --document-name AWS-StartPortForwardingSession --parameters '{"portNumber":["5432"],"localPortNumber":["5432"]}'.
If you still need a bastion for specific workflows (legacy tools, CI pipelines that predate SSM), harden it: no inbound SSH from 0.0.0.0/0, restrict to a known CIDR range, use host-based firewall (ufw deny incoming, allow only from your VPN CIDR), and enforce SSH certificate authorities rather than individual public keys.
DNS Security: DNSSEC and DoH
DNS is an overlooked attack surface. DNS hijacking and cache poisoning attacks redirect legitimate traffic to attacker-controlled servers. Two controls matter here.
DNSSEC adds cryptographic signatures to DNS responses. A resolver that validates DNSSEC will reject unsigned or tampered responses. For AWS Route 53, DNSSEC signing is available at the hosted zone level.
resource "aws_route53_hosted_zone_dnssec" "main" {
hosted_zone_id = aws_route53_zone.main.id
}
resource "aws_route53_key_signing_key" "main" {
hosted_zone_id = aws_route53_zone.main.id
key_management_service_arn = aws_kms_key.dnssec.arn
name = "dnssec-ksk"
}
resource "aws_kms_key" "dnssec" {
description = "DNSSEC KSK for ${var.domain}"
customer_master_key_spec = "ECC_NIST_P256"
key_usage = "SIGN_VERIFY"
deletion_window_in_days = 7
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable Route 53 DNSSEC"
Effect = "Allow"
Principal = {
Service = "dnssec-route53.amazonaws.com"
}
Action = ["kms:DescribeKey", "kms:GetPublicKey", "kms:Sign"]
Resource = "*"
},
{
Sid = "Admin access"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${var.account_id}:root"
}
Action = "kms:*"
Resource = "*"
}
]
})
}
DNS over HTTPS (DoH) encrypts resolver queries so they cannot be intercepted or modified in transit. For application code running inside your VPC, use Route 53 Resolver (which serves queries over the private DNS endpoint on 169.254.169.253). For developer machines and CI runners, configure DoH resolvers (Cloudflare 1.1.1.1 or Quad9 9.9.9.9 via DoH).
Egress Filtering
Most startup environments have completely unrestricted outbound internet access from private subnets. A compromised workload can exfiltrate data to any IP, download malware from any domain, or beacon to a C2 server. Egress filtering limits the blast radius.
Option 1: Security group egress rules. The simplest form. Deny all outbound by default; explicitly allow only the ports and destinations your workloads need. This does not filter by domain name, only by IP and port. For APIs that use fixed IPs, this works well.
Option 2: AWS Network Firewall. A managed stateful firewall that sits in your VPC and supports domain-based filtering, IDS/IPS signature matching, and TLS inspection. Traffic from private subnets routes through the firewall before hitting the internet gateway.
resource "aws_networkfirewall_rule_group" "egress_allow" {
capacity = 100
name = "${var.env}-egress-allow"
type = "STATEFUL"
rule_group {
rules_source {
rules_source_list {
generated_rules_type = "ALLOWLIST"
target_types = ["HTTP_HOST", "TLS_SNI"]
targets = [
".amazonaws.com",
".cloudflare.com",
"api.stripe.com",
"api.github.com",
"registry.npmjs.org",
]
}
}
}
}
The TLS_SNI target type inspects the Server Name Indication field of TLS handshakes without decrypting the payload. This filters HTTPS traffic by domain without needing a TLS inspection proxy.
Option 3: Squid or a managed proxy. For environments that need fine-grained control with logging, a forward proxy (Squid in an explicit proxy configuration, or a managed service like Zscaler) routes all outbound HTTP/HTTPS traffic. Workloads configure HTTP_PROXY and HTTPS_PROXY environment variables. The proxy enforces an allowlist and logs every request. The tradeoff: adding a proxy is another service to maintain and a potential single point of failure for all outbound traffic.
Hardening Checklist
VPC and network topology
- Three-tier subnet model: public, private, isolated. Isolated subnets have no internet route.
- NACLs deny all traffic to isolated subnets except from specific source subnets on specific ports.
- Security groups use source group ID references, not CIDR ranges, for intra-VPC traffic.
- No security group allows inbound from
0.0.0.0/0except the public ALB on port 443. - VPC Flow Logs enabled and shipped to CloudWatch or S3 with a 90-day retention policy.
Access and authentication
- SSH port (22) is not open to any public CIDR on any security group.
- SSM Session Manager (or GCP IAP) replaces bastion hosts for interactive access.
- RDS uses IAM authentication, not static passwords stored in environment variables.
- mTLS or workload identity is used between internal services.
- No IAM role with
*wildcard on sensitive actions (S3:GetObject, RDS:Connect, KMS:Decrypt).
DNS
- DNSSEC signing enabled on public hosted zones.
- Internal resolvers use private Route 53 Resolver endpoints, not internet-facing resolvers.
- No wildcard DNS records (
*.internal.yourdomain.com) unless explicitly required.
Egress
- Default-deny egress on security groups for all private and isolated workloads.
- Explicit egress rules allow only required ports and destinations.
- Egress filtering (Network Firewall or proxy) in place for workloads with broad outbound requirements.
Secrets and credentials
- No plaintext credentials in environment variables. Use Secrets Manager or Parameter Store with IAM-scoped access.
- Secrets rotation enabled for all RDS passwords stored in Secrets Manager.
- IMDSv2 enforced on all EC2 instances (blocks SSRF-based metadata credential theft).
# Enforce IMDSv2 on all instances
resource "aws_instance" "app" {
# ... other config
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # enforces IMDSv2
http_put_response_hop_limit = 1
}
}
Observability
- CloudTrail enabled with log file validation.
- GuardDuty enabled in all regions where you have resources.
- Alerts on: security group rule additions, IAM policy changes, root account usage, unexpected API calls from new regions.
Production Considerations
Separate AWS accounts per environment. Network controls inside a single account are helpful, but a compromised production IAM credential can affect staging if they share an account. AWS Organizations with Service Control Policies (SCPs) provide a harder boundary.
Treat VPC peering as extending your security boundary. When you peer two VPCs, traffic between them bypasses internet controls but is still subject to security groups and route tables. Define explicit security group rules for peered VPC traffic; do not rely on subnet CIDR allow-listing alone.
Private endpoints for AWS services. Without VPC endpoints, traffic to S3, DynamoDB, Secrets Manager, and similar services routes through the internet gateway even from private subnets. AWS PrivateLink endpoints keep this traffic inside the AWS network and let you apply endpoint policies to restrict access. The cost of interface endpoints is approximately $7.30 per month per endpoint per AZ. For Secrets Manager and KMS, the security benefit is worth it. For S3, a gateway endpoint is free.
# S3 gateway endpoint: free, keeps S3 traffic inside AWS backbone
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = aws_route_table.private[*].id
}
# Secrets Manager interface endpoint: keeps secret reads off the internet
resource "aws_vpc_endpoint" "secrets_manager" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = aws_subnet.private[*].id
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
Test your controls. A security group you think is locked down may have an exception added during an incident that was never removed. Run a quarterly review of all security group rules using AWS Config rules or a custom script that flags any rule allowing inbound traffic from 0.0.0.0/0 outside of the expected ALB rules. GuardDuty findings correlate network behavior against known threat patterns automatically, but they are not a substitute for reviewing the actual rules in your infrastructure.
The Network Is Not the Perimeter
The shift from perimeter security to zero trust is not philosophical: it is a response to a specific failure mode. Perimeter security assumes everything inside is trusted, so a single breach gives an attacker broad access. Zero trust removes that assumption by making every connection authenticated and authorized.
For most startups, this does not mean deploying a full zero trust architecture on day one. It means: do not conflate network location with authorization, enforce mTLS or workload identity between services that matter, restrict egress to what is necessary, and use SSM instead of bastion hosts. The hardening checklist above represents roughly two weeks of infrastructure work for a small team. Most of it is configuration, not custom code. The value compounds: each control you put in place now is one fewer gap an auditor, a customer’s security team, or an attacker will find later.
Network security done at the infrastructure layer is also significantly cheaper to maintain than security bolted on at the application layer after the fact. A security group rule costs nothing to maintain. A VPC endpoint costs seven dollars a month. Rebuilding trust with a customer after a breach does not.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.