VPC Traffic Mirroring: Copy Network Traffic for Security and Debugging

8 min read

Sometimes you need to see what's actually on the wire. Maybe you're debugging a flaky connection between services, hunting for data exfiltration, or validating that TLS is actually being used where you think it is. You can't just tcpdump on an EC2 instance's ENI from outside — AWS doesn't give you that level of access to the underlying hypervisor network.

That's where VPC Traffic Mirroring comes in. It copies packets at the ENI level and delivers them to a target you control, wrapped in VXLAN encapsulation. You get a full packet capture without installing agents on your workloads.

How Traffic Mirroring Works

The architecture has three components:

  1. Mirror Source — the elastic network interface (ENI) you want to capture traffic from
  2. Mirror Filter — rules that control which packets get copied (think of it as a lightweight BPF)
  3. Mirror Target — where the copied packets land (an ENI, a Network Load Balancer, or a Gateway Load Balancer endpoint)
Architecture diagram showing an EC2 source instance with its ENI connected via a mirror session to a monitoring EC2 instance. The mirror filter sits between source and target, and packets are encapsulated in VXLAN on UDP 4789.
Traffic flows from the source ENI through the filter, gets VXLAN-encapsulated, and arrives at the target on UDP 4789.

The key thing to understand: mirrored traffic is not routed through your VPC's normal path. AWS copies the packets at the hypervisor level and delivers them via VXLAN encapsulation to the target's IP address on UDP port 4789. The target must explicitly allow inbound UDP 4789 in its security group.

VXLAN Encapsulation

Every mirrored packet arrives at the target wrapped in a VXLAN header. The outer packet looks like this:

┌─────────────────────────────────────────────┐
│ Outer Ethernet Header                       │
├─────────────────────────────────────────────┤
│ Outer IP Header                             │
│   Src: source ENI primary IP                │
│   Dst: target ENI/NLB IP                    │
├─────────────────────────────────────────────┤
│ Outer UDP Header                            │
│   Src: hash-derived port                    │
│   Dst: 4789                                 │
├─────────────────────────────────────────────┤
│ VXLAN Header                                │
│   VNI: session virtual network ID           │
├─────────────────────────────────────────────┤
│ Original mirrored packet (L2 frame)         │
│   (the actual traffic you want to inspect)  │
└─────────────────────────────────────────────┘

Your monitoring tool needs to understand VXLAN decapsulation. Most do — Suricata, Zeek, and even plain tcpdump can handle it with the right flags.

What You Need Before Starting

  • A VPC with at least two subnets (source and target can be in the same subnet, but separating them is cleaner)
  • An EC2 instance to mirror from (the source) — must use a supported instance type
  • An EC2 instance (or NLB) to mirror to (the target)
  • The target's security group must allow inbound UDP 4789 from the source

Setting It Up with Terraform

Here's a complete Terraform configuration that mirrors all TCP traffic from a source instance to a monitoring instance running in the same VPC.

Step 1: Create the Mirror Target

The target is the ENI of your monitoring instance:

resource "aws_ec2_traffic_mirror_target" "monitor" {
  description          = "Traffic mirror target - monitoring instance"
  network_interface_id = aws_instance.monitor.primary_network_interface_id
 
  tags = {
    Name = "traffic-mirror-target"
  }
}

If you're using a Network Load Balancer instead (for scaling to multiple monitoring instances):

resource "aws_ec2_traffic_mirror_target" "nlb" {
  description                = "Traffic mirror target - NLB"
  network_load_balancer_arn  = aws_lb.mirror_nlb.arn
 
  tags = {
    Name = "traffic-mirror-target-nlb"
  }
}

Step 2: Create the Mirror Filter

Filters control what gets mirrored. Each filter has inbound and outbound rules. Here's one that captures all TCP traffic but ignores SSH (so you don't mirror your own admin sessions into an infinite loop):

resource "aws_ec2_traffic_mirror_filter" "tcp_no_ssh" {
  description = "Mirror all TCP except SSH"
 
  tags = {
    Name = "tcp-no-ssh-filter"
  }
}
 
# Accept all inbound TCP
resource "aws_ec2_traffic_mirror_filter_rule" "inbound_tcp" {
  traffic_mirror_filter_id = aws_ec2_traffic_mirror_filter.tcp_no_ssh.id
  description              = "Accept all inbound TCP"
  rule_number              = 100
  rule_action              = "accept"
  direction                = "ingress"
  protocol                 = 6  # TCP
 
  destination_cidr_block = "0.0.0.0/0"
  source_cidr_block      = "0.0.0.0/0"
}
 
# Reject inbound SSH (higher priority)
resource "aws_ec2_traffic_mirror_filter_rule" "reject_inbound_ssh" {
  traffic_mirror_filter_id = aws_ec2_traffic_mirror_filter.tcp_no_ssh.id
  description              = "Reject inbound SSH"
  rule_number              = 50
  rule_action              = "reject"
  direction                = "ingress"
  protocol                 = 6
 
  destination_cidr_block   = "0.0.0.0/0"
  source_cidr_block        = "0.0.0.0/0"
  destination_port_range {
    from_port = 22
    to_port   = 22
  }
}
 
# Accept all outbound TCP
resource "aws_ec2_traffic_mirror_filter_rule" "outbound_tcp" {
  traffic_mirror_filter_id = aws_ec2_traffic_mirror_filter.tcp_no_ssh.id
  description              = "Accept all outbound TCP"
  rule_number              = 100
  rule_action              = "accept"
  direction                = "egress"
  protocol                 = 6
 
  destination_cidr_block = "0.0.0.0/0"
  source_cidr_block      = "0.0.0.0/0"
}
 
# Reject outbound SSH
resource "aws_ec2_traffic_mirror_filter_rule" "reject_outbound_ssh" {
  traffic_mirror_filter_id = aws_ec2_traffic_mirror_filter.tcp_no_ssh.id
  description              = "Reject outbound SSH"
  rule_number              = 50
  rule_action              = "reject"
  direction                = "egress"
  protocol                 = 6
 
  destination_cidr_block   = "0.0.0.0/0"
  source_cidr_block        = "0.0.0.0/0"
  destination_port_range {
    from_port = 22
    to_port   = 22
  }
}

Step 3: Create the Mirror Session

The session ties source, target, and filter together:

resource "aws_ec2_traffic_mirror_session" "main" {
  description              = "Mirror TCP traffic from app server"
  traffic_mirror_filter_id = aws_ec2_traffic_mirror_filter.tcp_no_ssh.id
  traffic_mirror_target_id = aws_ec2_traffic_mirror_target.monitor.id
  network_interface_id     = aws_instance.app_server.primary_network_interface_id
  session_number           = 1
 
  tags = {
    Name = "app-server-mirror-session"
  }
}

session_number determines priority when multiple sessions share a source ENI. Lower numbers are evaluated first, and each packet is only mirrored once — if session 1 matches, session 2 won't see it.

Step 4: Security Group for the Target

The target must accept VXLAN traffic:

resource "aws_security_group_rule" "allow_vxlan" {
  type              = "ingress"
  from_port         = 4789
  to_port           = 4789
  protocol          = "udp"
  cidr_blocks       = [data.aws_vpc.main.cidr_block]
  security_group_id = aws_security_group.monitor.id
  description       = "Allow VXLAN from mirror sources"
}
Sequence diagram showing: 1) App server sends normal traffic, 2) Hypervisor copies matching packets, 3) Packets are VXLAN-encapsulated and sent to the monitor instance on UDP 4789, 4) Monitor instance decapsulates and inspects.
The mirroring happens at the hypervisor — your application never knows its traffic is being copied.

Receiving Mirrored Traffic

Once the session is active, VXLAN-encapsulated packets start arriving at your target on UDP 4789. Here's how to inspect them with common tools.

tcpdump (Quick and Dirty)

# Capture raw VXLAN packets
sudo tcpdump -i eth0 udp port 4789 -w /tmp/mirror-capture.pcap
 
# Decode VXLAN inline
sudo tcpdump -i eth0 udp port 4789 -n -e

The -w flag writes a pcap you can open in Wireshark later. Wireshark natively decodes VXLAN, so you'll see the inner packets as if you were on the source's wire.

Suricata (IDS/IPS)

Suricata can listen directly on a VXLAN interface. Create a VXLAN tunnel device and point Suricata at it:

# Create a VXLAN interface that decapsulates traffic from the mirror
sudo ip link add vxlan0 type vxlan id 0 dev eth0 dstport 4789 local $(hostname -I | awk '{print $1}')
sudo ip link set vxlan0 up
 
# Run Suricata on the decapsulated interface
sudo suricata -c /etc/suricata/suricata.yaml -i vxlan0

Zeek (Network Analysis)

Same approach — point Zeek at the VXLAN interface:

sudo ip link add vxlan0 type vxlan id 0 dev eth0 dstport 4789 local $(hostname -I | awk '{print $1}')
sudo ip link set vxlan0 up
 
# Run Zeek
sudo zeek -i vxlan0 local

Zeek will produce its standard log files (conn.log, dns.log, http.log, etc.) from the mirrored traffic.

Architecture Patterns

Pattern 1: Single Monitor Instance

Simplest setup. One source, one target. Good for debugging a specific instance.

Diagram showing a single app server ENI mirrored to a single monitor instance ENI, both in the same subnet.
Single source → single target. Simple, but doesn't scale.

Pattern 2: NLB Fan-Out

Multiple monitoring instances behind a Network Load Balancer. The NLB distributes mirrored traffic across the fleet using a 5-tuple hash, so each monitor sees a subset of flows.

Diagram showing multiple app server ENIs mirrored to an NLB target, which distributes to a fleet of monitor instances in an Auto Scaling group.
NLB target distributes mirrored traffic across a monitoring fleet. Use this when one instance can't keep up with the packet rate.

Pattern 3: Gateway Load Balancer

For inline inspection appliances (third-party firewalls, deep packet inspection). The GWLB endpoint acts as the mirror target and routes traffic through the appliance transparently.

Diagram showing app server ENI mirrored to a Gateway Load Balancer endpoint, which routes to third-party appliance instances.
GWLB pattern for third-party security appliances that need to see all traffic.

Costs and Limits

A few things to keep in mind:

  • No per-packet charge for mirroring itself, but you pay for the data transfer between source and target if they're in different AZs
  • Bandwidth: mirrored traffic counts against the source instance's network throughput. If your instance is already saturated, mirroring adds overhead
  • Session limit: 3 mirror sessions per ENI by default (adjustable via quota request)
  • Packet truncation: you can set packet_length on the session to only mirror the first N bytes of each packet — useful for capturing headers without the payload when you're bandwidth-constrained
resource "aws_ec2_traffic_mirror_session" "headers_only" {
  # ... other fields ...
  packet_length = 128  # Only mirror first 128 bytes
}

Troubleshooting

If you're not seeing traffic on the target:

  1. Security group: does the target allow inbound UDP 4789 from the source's IP/subnet?
  2. NACL: does the subnet's network ACL allow UDP 4789 in both directions?
  3. Instance type: is the source a supported Nitro instance? T2s silently fail.
  4. Filter rules: did you accidentally reject everything? Check rule numbers — lower numbers evaluate first.
  5. Connectivity: use VPC Reachability Analyzer to confirm the source can reach the target.
  6. Already mirrored: if another session with a lower session_number already matched the packet, it won't be mirrored again.

When to Use Traffic Mirroring

  • Incident response: capture traffic from a compromised instance without alerting the attacker
  • Compliance auditing: prove that sensitive data isn't leaving the VPC unencrypted
  • Network debugging: see exactly what's on the wire when application logs aren't enough
  • IDS/IPS: feed traffic to Suricata or Zeek without deploying agents on every instance
  • Third-party appliance integration: route mirrored traffic through vendor security tools via GWLB

Wrapping Up

Traffic Mirroring gives you packet-level visibility into your VPC without modifying your workloads. The setup is straightforward — a target, a filter, and a session — and Terraform makes it reproducible. The main gotchas are instance type support, security group rules for UDP 4789, and remembering that mirrored traffic is VXLAN-encapsulated.

Start with a single source and tcpdump on the target to prove the plumbing works, then graduate to Suricata or Zeek when you're ready for automated analysis.