AWS
Cloud Asset Inventory
FinOps
Tutorials
Find S3 and DynamoDB Gateway Endpoint Gaps With Aurora PostgreSQL's New S3 Queries
Your cloud asset inventory can list every VPC without an S3 or DynamoDB gateway endpoint in one query. It can't tell you whether anything in those VPCs talks to S3 or DynamoDB, and that's the number you need before you touch a route table. That number is already sitting in your VPC flow logs, often as Parquet files in a bucket nobody queries.
Until recently, getting those flow logs next to your inventory meant a separate query engine such as Athena, or a pipeline that copied the logs into a database. That changed on 30 September 2026, when AWS made direct querying of Apache Iceberg and Parquet data in S3 generally available in Aurora PostgreSQL through a new
aurora_analytics extension. It's in all commercial regions and AWS GovCloud (US), with no extra charge beyond Aurora compute and S3 requests. This post is our first real use of it. We tried a small setup: sync the AWS inventory into Aurora with CloudQuery, point foreign tables at the existing flow logs, and join both against AWS's published IP ranges. The output is one row per VPC and service, with the gigabytes a gateway endpoint would have carried and an upper bound on the NAT charges it could remove. If the traffic is zero, skip the change; if it isn't, you know which VPCs deserve a closer look. Every query below ran end to end in our demo account on 9 October 2026.Why Not Add Gateway Endpoints Everywhere? #
Gateway endpoints for S3 and DynamoDB have no hourly or data-processing charge. Without one, traffic from private subnets reaches those services through a NAT gateway, which bills $0.045 per GB processed in US East (Ohio). A VPC that pulls 10 TB a month from S3 through NAT pays about $450 a month for that path alone.
The catch is that a gateway endpoint is a route-table change. It adds a prefix-list route to every route table you associate, and that route wins over
0.0.0.0/0 by longest-prefix match. Bucket policies that rely on aws:SourceIp stop matching, because the key isn't present for requests that arrive through a VPC endpoint. An endpoint policy can also block access that worked yesterday. Rolling that out across dozens of VPCs owned by different teams is real work, so we'd rather know which VPCs benefit before starting.How Does It Fit Together? #
CloudQuery syncs the inventory into Aurora. The flow logs stay in S3, and Aurora reads them only when a query runs. The inventory also tells the setup script where each VPC's flow logs live, so you never type a bucket name.
Step 1: Create an Aurora PostgreSQL Cluster With aurora_analytics #
The extension needs Aurora PostgreSQL 17.11 or later, or 18.6 or later. We used Serverless v2 with a minimum of 0 ACU, so the cluster pauses after 10 idle minutes and stops billing for compute while it sits there.
aws rds create-db-cluster-parameter-group \
--db-cluster-parameter-group-name aurora-analytics-pg17 \
--db-parameter-group-family aurora-postgresql17 \
--description "aurora_analytics enabled"
aws rds modify-db-cluster-parameter-group \
--db-cluster-parameter-group-name aurora-analytics-pg17 \
--parameters ParameterName=aurora_analytics.enabled,ParameterValue=1,ApplyMethod=immediate
aws rds create-db-cluster \
--db-cluster-identifier aurora-analytics \
--engine aurora-postgresql --engine-version 17.11 \
--db-cluster-parameter-group-name aurora-analytics-pg17 \
--master-username postgres --manage-master-user-password \
--database-name inventory \
--db-subnet-group-name <your-subnet-group> \
--vpc-security-group-ids <your-db-security-group> \
--serverless-v2-scaling-configuration MinCapacity=0,MaxCapacity=8,SecondsUntilAutoPause=600 \
--storage-encrypted
aws rds create-db-instance \
--db-instance-identifier aurora-analytics-1 \
--db-cluster-identifier aurora-analytics \
--engine aurora-postgresql --db-instance-class db.serverless
The cluster then needs read access to your flow-log buckets. Create an IAM role that
rds.amazonaws.com can assume, grant it s3:GetObject, s3:ListBucket, and s3:GetBucketLocation on those buckets, and attach it with the AuroraAnalytics feature name:aws rds add-role-to-db-cluster \
--db-cluster-identifier aurora-analytics \
--role-arn arn:aws:iam::111122223333:role/aurora-analytics-s3-role \
--feature-name AuroraAnalytics
Once the role is active, enable the extension. It creates the foreign data wrapper and a server named
aurora_analytics_server for you:create extension aurora_analytics;
Two things the launch post leaves out cost us time. The
aurora_analytics.enabled parameter is off by default, and until you set it to 1 in the cluster parameter group, every query fails with [Aurora Analytics] Executor Error: aurora_analytics is not enabled. The cluster also needs a network path to S3. Our VPC had no NAT gateway, so we added an S3 gateway endpoint to the database subnets' route table (yes, we noticed the irony).Step 2: Sync Your AWS Inventory Into the Same Database #
The join only works if the inventory and the flow logs live in one database. In CloudQuery Platform, add a PostgreSQL Destination that points at the Aurora cluster, next to the default ClickHouse destination. The Platform has to reach the cluster's endpoint, though, and ours sits in private subnets with no inbound path. We ran the CloudQuery CLI over an SSH tunnel instead, with this configuration file:
kind: source
spec:
name: aws
path: cloudquery/aws
registry: cloudquery
version: "v34.9.0"
tables:
- aws_ec2_vpcs
- aws_ec2_vpc_endpoints
- aws_ec2_flow_logs
- aws_ec2_nat_gateways
destinations: ["postgresql"]
spec:
regions: ["us-east-1"]
---
kind: destination
spec:
name: postgresql
path: cloudquery/postgresql
registry: cloudquery
version: "v8.0.0"
spec:
connection_string: "${PG_CONN}"
We tested with AWS Source v34.9.0 and PostgreSQL Destination v8.0.0; the current releases on the CloudQuery Hub are v35.0.0 and v8.17.0. The sync took 4 seconds for 12 resources in one region. For a multi-account estate, add your accounts or your AWS Organization (
org:) to the source spec. Every row carries account_id and region, so the queries below work across accounts without changes.Step 3: Which VPCs Are Missing a Gateway Endpoint? #
This view returns every VPC with an active flow log delivered to S3 that's missing an S3 gateway endpoint, a DynamoDB gateway endpoint, or both. It counts NAT gateways too, because that's where any savings come from. A VPC can have more than one flow log, and measuring two of them would count the same traffic twice, so
distinct on keeps one per VPC: Parquet first, then traffic type ALL, then the newest.create or replace view vpc_gateway_endpoint_gaps as
with gw as (
select vpc_id,
bool_or(service_name like '%.s3') as has_s3_gateway,
bool_or(service_name like '%.dynamodb') as has_ddb_gateway
from aws_ec2_vpc_endpoints
where vpc_endpoint_type = 'Gateway' and state = 'available'
group by vpc_id
),
nat as (
select vpc_id, count(*) as nat_gateways
from aws_ec2_nat_gateways where state = 'available'
group by vpc_id
)
select distinct on (v.vpc_id)
v.account_id, v.region, v.vpc_id,
coalesce(gw.has_s3_gateway, false) as has_s3_gateway,
coalesce(gw.has_ddb_gateway, false) as has_ddb_gateway,
coalesce(nat.nat_gateways, 0) as nat_gateways,
fl.flow_log_id, fl.traffic_type,
replace(fl.log_destination, 'arn:aws:s3:::', 's3://') as flow_log_s3_uri,
fl.destination_options->>'FileFormat' as file_format
from aws_ec2_vpcs v
join aws_ec2_flow_logs fl
on fl.resource_id = v.vpc_id
and fl.flow_log_status = 'ACTIVE'
and fl.log_destination_type = 's3'
left join gw on gw.vpc_id = v.vpc_id
left join nat on nat.vpc_id = v.vpc_id
where not (coalesce(gw.has_s3_gateway, false) and coalesce(gw.has_ddb_gateway, false))
order by v.vpc_id,
(fl.destination_options->>'FileFormat' = 'parquet') desc,
(fl.traffic_type = 'ALL') desc,
fl.creation_time desc;
In our demo account it returned one VPC. It had the S3 gateway endpoint we added in Step 1, no DynamoDB gateway endpoint, and no NAT gateway:
The view keeps only flow logs delivered to S3, since a CloudWatch Logs destination can't be read this way. Format matters as well:
aurora_analytics reads Parquet and Iceberg, not the default plain-text flow-log format. You can't change the format of an existing flow log, so if yours are plain text, create a second flow log with FileFormat=parquet and let it collect a week or two of data before you measure.Step 4: Turn Each VPC's Flow Logs Into a Foreign Table #
Your inventory already knows each flow log's ID and bucket, so there's no reason to write foreign tables by hand. This block creates one per candidate VPC, named after its flow log, plus a view that unions them:
do $$
declare
r record;
t text;
parts text[] := '{}';
begin
drop view if exists candidate_vpc_flow_logs;
for r in
select vpc_id, flow_log_id, flow_log_s3_uri
from vpc_gateway_endpoint_gaps
where file_format = 'parquet'
loop
t := 'flow_logs_' || replace(r.flow_log_id, '-', '_');
execute format('drop foreign table if exists %I', t);
begin
execute format(
'create foreign table %I () server aurora_analytics_server
options (location %L, format %L)',
t,
rtrim(r.flow_log_s3_uri, '/') || '/AWSLogs/**/*_' || r.flow_log_id || '_*.parquet',
'parquet');
exception when others then
raise notice 'skipping % (%): %', r.vpc_id, r.flow_log_id, sqlerrm;
continue;
end;
parts := parts || format(
'select %L::text as vpc_id, interface_id, srcaddr, dstaddr, srcport, dstport,
protocol, packets, bytes, start, "end", action from %I',
r.vpc_id, t);
end loop;
if array_length(parts, 1) is null then
raise exception 'No candidate VPC has Parquet flow-log files yet. Let the flow logs collect data, then rerun this block.';
end if;
execute 'create view candidate_vpc_flow_logs as '
|| array_to_string(parts, ' union all ');
end $$;
The empty column list
() tells Aurora to infer the schema from the Parquet footer. With the default flow-log fields, you get srcaddr and dstaddr as text, ports and protocol as integers, and packets, bytes, start, and end as bigint. The glob matters more than it looks. Flow logs from several VPCs often share one bucket, and a prefix such as .../vpcflowlogs/us-east-1/ would hand every VPC's records to every table. Each file name carries its flow log ID (111122223333_vpcflowlogs_us-east-1_fl-0123456789abcdef0_20261001T1600Z_f3071d68.log.parquet), so the pattern **/*_<flow_log_id>_*.parquet reads only that log's files, whether or not you turned on Hive-compatible prefixes. A flow log with no files yet fails with No files found that match the pattern; the block skips it with a notice instead of aborting. If every flow log is skipped, the block raises an error instead of finishing without a view, and because the whole block rolls back, a previous candidate_vpc_flow_logs survives. Re-run the block after each sync to pick up new VPCs. It drops candidate_vpc_flow_logs first, because PostgreSQL won't drop a foreign table that a view still depends on; a do block runs as one transaction, so readers never see the view missing.Step 5: Load the Published S3 and DynamoDB IP Ranges #
Flow logs record IP addresses, not service names. AWS publishes which prefixes belong to which service in
ip-ranges.json, so we pulled out the S3 and DynamoDB entries with jq and loaded them into a cidr column with a GiST index:curl -s https://ip-ranges.amazonaws.com/ip-ranges.json -o ip-ranges.json
{
echo "begin;"
echo "drop table if exists aws_service_ip_ranges;"
echo "create table aws_service_ip_ranges (prefix cidr not null, service text not null,"
echo " region text not null, network_border_group text);"
echo "insert into aws_service_ip_ranges values"
jq -r '[.prefixes[] | {p:.ip_prefix, s:.service, r:.region, n:.network_border_group}]
+ [.ipv6_prefixes[] | {p:.ipv6_prefix, s:.service, r:.region, n:.network_border_group}]
| map(select(.s=="S3" or .s=="DYNAMODB")) | unique
| map("(\(.p|@sh), \(.s|@sh), \(.r|@sh), \(.n|@sh))")
| join(",\n")' ip-ranges.json
echo ";"
echo "create index on aws_service_ip_ranges using gist (prefix inet_ops);"
echo "commit;"
} > aws_service_ip_ranges.sql
psql "$PG_CONN" -f aws_service_ip_ranges.sql
The 9 October 2026 snapshot held 1,242 prefixes:
That zero comes back later: AWS lists no IPv6 prefixes for DynamoDB. The file changes regularly, so reload it on the same schedule as your inventory sync.
Step 6: How Much Traffic Would a Gateway Endpoint Carry? #
This query returns one row per candidate VPC and service. It matches each flow record against the IP ranges on either side, so request and response bytes both count. It then projects the total to 30 days and prices it at the NAT data-processing rate. Treat that dollar figure as a ceiling, not a measured NAT charge; the caveats follow the query.
with nat_enis as (
select a->>'NetworkInterfaceId' as interface_id
from aws_ec2_nat_gateways, jsonb_array_elements(nat_gateway_addresses) a
),
flows as (
select f.vpc_id, f.interface_id, f.srcaddr::inet as src, f.dstaddr::inet as dst,
f.bytes, f.start, f."end"
from candidate_vpc_flow_logs f
where f.srcaddr <> '-' and f.dstaddr <> '-'
and not exists (select 1 from nat_enis n where n.interface_id = f.interface_id)
),
matched as (
select f.*, r.service
from flows f
join lateral (
select service from aws_service_ip_ranges r
where f.dst <<= r.prefix or f.src <<= r.prefix
order by masklen(r.prefix) desc
limit 1
) r on true
),
window_days as (
select vpc_id, greatest((max("end") - min(start)) / 86400.0, 1) as days
from candidate_vpc_flow_logs group by vpc_id
)
select g.vpc_id, s.service,
case s.service when 'S3' then g.has_s3_gateway else g.has_ddb_gateway end as has_gateway,
g.nat_gateways,
round(w.days, 1) as days_of_logs,
count(m.*) as flow_records,
count(distinct m.interface_id) as enis,
round(coalesce(sum(m.bytes), 0) / 1e9, 3) as gb_total,
round(coalesce(sum(m.bytes), 0) / 1e9 / w.days * 30, 3) as gb_per_30_days,
case when g.nat_gateways > 0
and not (case s.service when 'S3' then g.has_s3_gateway else g.has_ddb_gateway end)
then round(coalesce(sum(m.bytes), 0) / 1e9 / w.days * 30 * 0.045, 2)
else 0 end as max_nat_savings_usd_30d
from vpc_gateway_endpoint_gaps g
cross join (values ('S3'), ('DYNAMODB')) s(service)
join window_days w on w.vpc_id = g.vpc_id
left join matched m on m.vpc_id = g.vpc_id and m.service = s.service
group by g.vpc_id, s.service, g.has_s3_gateway, g.has_ddb_gateway, g.nat_gateways, w.days
order by g.vpc_id, s.service;
Three choices in this query change the answer. First, NAT gateway network interfaces are excluded. If the NAT gateway's own subnet is logged, every byte shows up twice (once on the workload's interface and once on the NAT gateway's), and the
nat_enis CTE reads the NAT interface IDs straight from the inventory. The filter uses not exists rather than not in on purpose: NetworkInterfaceId is optional on a NAT gateway address, and a single null in a not in list filters out every row, which would turn real traffic into a convincing 0 GB. Second, each flow goes to the most specific matching prefix. The S3 IPv4 ranges in our snapshot don't overlap, but order by masklen(...) desc keeps the query correct if a future file adds nested ranges. Third, savings are zero when a VPC has no NAT gateway, because traffic through an internet gateway pays no processing fee. They're also zero for a service that already has a gateway endpoint, since that traffic already skips NAT. Swap 0.045 for your region's rate.Even with those rules,
max_nat_savings_usd_30d is an upper bound based on traffic, not proof of NAT charges. A NAT gateway in the VPC doesn't mean every matched byte passes through it. Workloads in public subnets reach S3 and DynamoDB through the internet gateway, and IPv6 traffic leaves through an egress-only internet gateway; neither pays NAT processing. Flow logs record addresses, not the route a packet took. To turn the ceiling into a firm number, keep only IPv4 flows from network interfaces in subnets whose route table sends 0.0.0.0/0 to a NAT gateway. The aws_ec2_network_interfaces and aws_ec2_route_tables tables in your inventory hold that mapping, so add them to the sync if you need it.Here's how we read each row:
What Did We Find in Our Demo Account? #
Our demo VPC had 8 days of Parquet flow logs: 5,041 files, about 40 MB, and roughly 424,000 records. It has no NAT gateway, so estimated savings came out to $0 for both services. The S3 and DynamoDB traffic broke down like this:
The verdict for this VPC is to leave it alone. S3 traffic is real but small, and with no NAT gateway there's nothing to save. DynamoDB traffic was only our own test calls, so a DynamoDB gateway endpoint would carry nothing. The surprise was the top row: the biggest S3 consumer was the analysis itself, because Aurora reads the flow logs through the VPC. If your cluster lives in a VPC you're measuring, exclude its network interfaces.
What Tripped Us Up? #
The first scan is slow when files are small. Flow logs land as many small Parquet files, and our first full scan of 5,041 files took 6.5 to 7 minutes at low capacity. Repeat queries took 5 to 16 seconds. If you'll query a VPC more than once, materialize it with
create table ... as select * from flow_logs_fl_... and query the copy.S3 credentials lag behind the wake-up from 0 ACU. When the paused cluster wakes up, PostgreSQL accepts connections in seconds, but for the first one to two minutes every foreign-table query fails with
credential to access AWS service is unavailable and a hint to attach an IAM role, even though the role is attached and active. We timed it at 95 to 115 seconds after the cluster woke up. Wait and retry before you start debugging IAM.The RDS Data API won't run these queries. It cancels any statement after 45 seconds, and a cold scan blows past that. Use
psql or another PostgreSQL client.DynamoDB over IPv6 doesn't show up. With no IPv6 DynamoDB prefixes in
ip-ranges.json, traffic to the dual-stack endpoint dynamodb.us-east-1.api.aws lands in 2600:1fbb:8000::/38, which AWS publishes as AMAZON and EC2. Adding that prefix would label EC2 traffic as DynamoDB, so we left it out. If your SDKs use dual-stack endpoints, treat the DynamoDB numbers as a floor.IPv4 gateway endpoints don't route IPv6. An endpoint with the IPv4 address type adds only the IPv4 prefix list. IPv6 traffic, like the 86.5 MB S3 transfer above, keeps leaving through the egress-only internet gateway. The dual-stack address type routes both.
Your flow logs need the right fields. The queries use
srcaddr, dstaddr, bytes, start, and end, all of which are in the default format. A flow log that records only REJECT traffic can't measure successful transfers.Where to Go From Here #
The inventory half of this (VPCs with a NAT gateway and no S3 gateway endpoint) works on its own as a CloudQuery Policy, so new VPCs get flagged as soon as they appear. The flow-log half tells you which of those flags are worth acting on. Start with the AWS Source and PostgreSQL Destination documentation.
Find the Gateway Endpoints Worth Adding
CloudQuery puts your AWS inventory in SQL next to the data that explains it. Tell us how many VPCs you're running and we'll show you how to size the endpoint gap across all of them.
FAQs #
Which Aurora PostgreSQL versions support aurora_analytics? #
Aurora PostgreSQL 17.11 and later, and 18.6 and later. AWS announced the feature on 30 September 2026, so older clusters need an engine upgrade first. You also need to set
aurora_analytics.enabled to 1 in the cluster parameter group, because it's off by default.Does this work with Aurora Serverless v2 scaled to zero? #
Yes. We ran everything on a Serverless v2 cluster with a minimum of 0 ACU. The first query after the cluster pauses waits for it to resume, and cold flow-log scans are slower at low capacity.
Can I query VPC flow logs stored in CloudWatch Logs? #
No.
aurora_analytics reads Parquet and Iceberg data in S3. Flow logs need to be delivered to S3 in Parquet format.Can I convert existing plain-text flow logs to Parquet? #
You can't change the format of an existing flow log. Create a second flow log for the same VPC with
FileFormat=parquet, and measure once it has collected enough days of data.Why does the query exclude NAT gateway network interfaces? #
When the NAT gateway's subnet is also logged, each byte appears on the workload's interface and again on the NAT gateway's interface. Excluding the NAT gateway interfaces counts each transfer once.
Does a gateway endpoint save money if my VPC has no NAT gateway? #
Not on data processing. Traffic through an internet gateway to S3 or DynamoDB in the same region has no NAT processing fee, so the endpoint changes the route but not the bill. Even in a VPC with a NAT gateway, the query's savings figure is an upper bound, because traffic from public subnets and over IPv6 skips NAT.
Can CloudQuery Platform write directly to the Aurora cluster? #
Yes, as an additional PostgreSQL Destination next to the default ClickHouse destination, as long as the Platform can reach the cluster's endpoint. For a cluster in private subnets, run the CloudQuery CLI from a host that can reach it.
Why might some DynamoDB traffic be missing? #
AWS doesn't publish IPv6 prefixes for DynamoDB in
ip-ranges.json. Requests to the dual-stack DynamoDB endpoint over IPv6 fall into ranges labeled AMAZON and EC2, so this method can't attribute them to DynamoDB.Try CloudQuery yourself
Self-hosted, open source, and free to start. Or schedule a demo to see the managed version.