Photo by intrapixel on Unsplash
When an AWS VPN tunnel is down, DescribeVpnConnections shows it in VgwTelemetry: each of the two tunnels has a Status of UP or DOWN, a LastStatusChange time and an AcceptedRouteCount. One tunnel down means you’ve lost redundancy; both down means an outage. Check both, then read the tunnel logs in CloudWatch Logs for the cause.
Every AWS Site-to-Site VPN connection has two tunnels, and AWS updates their endpoints one tunnel at a time. If only one tunnel was ever configured on your side, a routine update can take the whole link down. This example is for network and platform engineers who want to know, across Regions, which connections have an AWS VPN tunnel down right now and which are one failure away from an outage.
You get a TypeScript script for the AWS SDK for JavaScript v3 that reports both tunnels of every connection, BGP tunnels that are up but have accepted no routes, tunnel options worth a second look, and connections with no CloudWatch alarm on TunnelState. It’s report only and exits with code 2 when a connection has every tunnel down, so it works in a cron job.
Why is an AWS VPN tunnel down?
AWS’s troubleshooting flow for customer gateway devices goes in order: IKE security association, IPsec security association, IP connectivity through the tunnel, then BGP. A tunnel shows DOWN when one of those layers fails. The common causes:
- Only one tunnel configured on your device. The Site-to-Site VPN guide to tunnel endpoint replacements states that AWS applies tunnel endpoint updates to one tunnel at a time, and that you might see a brief loss of redundancy. With both tunnels configured, traffic moves to the other tunnel.
- IKE or IPsec proposal mismatch. Encryption, integrity or DH group settings on your device don’t match what the tunnel accepts.
- Nobody restarts the negotiation. By default, a tunnel’s
StartupActionisadd(your device must start IKE) and itsDPDTimeoutActionisclear(end the IKE session after a dead peer detection timeout). If your device doesn’t re-initiate, the tunnel stays down. - BGP down on a dynamic connection. IPsec is up but the BGP session isn’t established, so no routes are exchanged. The telemetry then shows the tunnel with few or no accepted routes.
- Your changes. Modifying tunnel options makes that tunnel unavailable during the update, and changing the target gateway or connection options takes both tunnels down while new endpoints are provisioned.
What does the script do?
- Reads every available VPN connection
DescribeVpnConnectionswith astatefilter ofavailable. The call isn’t paginated, so one request per Region returns them all. - Checks both tunnelsFor each
VgwTelemetryentry it prints status,LastStatusChange,AcceptedRouteCountand anyStatusMessage, then flags an outage (every tunnel down) or lost redundancy (one down). - Checks BGP routesOn connections that aren’t static-routes-only, a tunnel that’s
UPwith zero accepted routes is flagged. - Reviews tunnel optionsFrom
Options.TunnelOptionsit flags tunnels that still allow IKEv1, tunnels that wait for your device to re-initiate, and tunnels with tunnel activity logs turned off. - Looks for alarms
paginateDescribeAlarmsfinds alarms on theAWS/VPNTunnelStatemetric and matches them byVpnIdorTunnelIpAddress.
Prerequisites
- Node.js 18 or later, npm,
tsx,@aws-sdk/client-ec2and@aws-sdk/client-cloudwatch. - A read-only profile in each account that owns VPN connections. If yours terminate on a transit gateway, the example to find unused Transit Gateway attachments covers the other side of that hub.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadVpnConnectionsAndAlarms",
"Effect": "Allow",
"Action": [
"ec2:DescribeVpnConnections",
"cloudwatch:DescribeAlarms"
],
"Resource": "*"
}
]
}
Both actions are read-only. The IAM policy generator for TypeScript code builds the same list from any variant of the script.
The script to check Site-to-Site VPN tunnel status
// check-site-to-site-vpn-tunnels.ts
// Reports the status of both tunnels of every Site-to-Site VPN connection, flags connections with a
// tunnel down, BGP tunnels with no accepted routes, tunnel options worth a look, and connections with
// no CloudWatch TunnelState alarm. Report only. Exits with code 2 when any connection has both tunnels down.
// Usage:
// npx tsx check-site-to-site-vpn-tunnels.ts [--regions us-east-1,eu-west-1]
import { CloudWatchClient, paginateDescribeAlarms } from "@aws-sdk/client-cloudwatch";
import { DescribeVpnConnectionsCommand, EC2Client, type VgwTelemetry, type VpnConnection } from "@aws-sdk/client-ec2";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
interface Row {
Region: string;
VPN: string;
Routing: string;
Tunnel1: string;
Tunnel2: string;
Alarm: string;
Findings: string;
}
const day = (d: Date | undefined) => (d ? d.toISOString().slice(0, 16).replace("T", " ") : "?");
function describeTunnel(t: VgwTelemetry | undefined): string {
if (!t) return "missing";
const msg = t.StatusMessage ? ` "${t.StatusMessage}"` : "";
return `${t.Status ?? "?"} since ${day(t.LastStatusChange)}, ${t.AcceptedRouteCount ?? 0} routes${msg}`;
}
// VPN IDs and tunnel IPs that already have a CloudWatch alarm on AWS/VPN TunnelState.
async function alarmedTargets(cw: CloudWatchClient): Promise<Set<string>> {
const targets = new Set<string>();
for await (const page of paginateDescribeAlarms({ client: cw }, {})) {
for (const alarm of page.MetricAlarms ?? []) {
if (alarm.Namespace !== "AWS/VPN" || alarm.MetricName !== "TunnelState") continue;
for (const d of alarm.Dimensions ?? []) {
if ((d.Name === "VpnId" || d.Name === "TunnelIpAddress") && d.Value) targets.add(d.Value);
}
}
}
return targets;
}
function check(region: string, vpn: VpnConnection, alarms: Set<string>): { row: Row; outage: boolean } {
const findings: string[] = [];
const id = vpn.VpnConnectionId ?? "?";
const name = vpn.Tags?.find((t) => t.Key === "Name")?.Value;
const staticRoutes = vpn.Options?.StaticRoutesOnly === true;
const tunnels = vpn.VgwTelemetry ?? [];
const down = tunnels.filter((t) => t.Status !== "UP");
if (tunnels.length > 0 && down.length === tunnels.length) {
findings.push("OUTAGE: every tunnel is DOWN");
} else if (down.length > 0) {
findings.push(`no redundancy: ${down.map((t) => t.OutsideIpAddress).join(", ")} DOWN`);
}
if (!staticRoutes) {
for (const t of tunnels) {
if (t.Status === "UP" && (t.AcceptedRouteCount ?? 0) === 0) findings.push(`${t.OutsideIpAddress} UP with 0 BGP routes`);
}
}
// Tunnel options are matched to telemetry by the AWS-side outside IP address.
for (const opt of vpn.Options?.TunnelOptions ?? []) {
const ip = opt.OutsideIpAddress ?? "?";
if (opt.IkeVersions?.some((v) => v.Value === "ikev1")) findings.push(`${ip} allows IKEv1`);
if (opt.StartupAction !== "start" && opt.DpdTimeoutAction !== "restart") {
findings.push(`${ip} waits for your device to re-initiate`);
}
if (opt.LogOptions?.CloudWatchLogOptions?.LogEnabled !== true) findings.push(`${ip} tunnel logs off`);
}
const hasAlarm = alarms.has(id) || tunnels.some((t) => t.OutsideIpAddress !== undefined && alarms.has(t.OutsideIpAddress));
if (!hasAlarm) findings.push("no TunnelState alarm");
const target = vpn.TransitGatewayId ?? vpn.VpnGatewayId ?? vpn.CoreNetworkArn ?? "?";
return {
row: {
Region: region,
VPN: name ? `${id} (${name}) -> ${target}` : `${id} -> ${target}`,
Routing: staticRoutes ? "static" : "BGP",
Tunnel1: describeTunnel(tunnels[0]),
Tunnel2: describeTunnel(tunnels[1]),
Alarm: hasAlarm ? "yes" : "no",
Findings: findings.join("; ") || "ok",
},
outage: tunnels.length > 0 && down.length === tunnels.length,
};
}
async function main(): Promise<void> {
const rows: Row[] = [];
let outages = 0;
for (const region of regions) {
try {
const ec2 = new EC2Client({ region });
const cw = new CloudWatchClient({ region });
// DescribeVpnConnections isn't paginated; it returns every matching connection in one call.
const res = await ec2.send(new DescribeVpnConnectionsCommand({ Filters: [{ Name: "state", Values: ["available"] }] }));
const alarms = await alarmedTargets(cw);
for (const vpn of res.VpnConnections ?? []) {
const { row, outage } = check(region, vpn, alarms);
rows.push(row);
if (outage) outages++;
}
} catch (err) {
console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
}
}
console.log(`Site-to-Site VPN connections: ${rows.length}, with every tunnel down: ${outages}`);
if (rows.length > 0) console.table(rows);
console.log("Report only. No VPN, tunnel or alarm settings were changed.");
if (outages > 0) process.exitCode = 2;
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node
# Check every VPN connection in two Regions
AWS_PROFILE=readonly npx tsx check-site-to-site-vpn-tunnels.ts --regions eu-west-2,us-east-1
echo "exit code: $?"
Sample output
Site-to-Site VPN connections: 2, with every tunnel down: 1
┌─────────┬─────────────┬─────────────────────────────────┬──────────┬─────────────────────────────────────────┬─────────────────────────────────────────┬───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Region │ VPN │ Routing │ Tunnel1 │ Tunnel2 │ Alarm │ Findings │
├─────────┼─────────────┼─────────────────────────────────┼──────────┼─────────────────────────────────────────┼─────────────────────────────────────────┼───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 0 │ 'eu-west-2' │ 'vpn-0a1 (dc-london) -> vgw-01' │ 'BGP' │ 'DOWN since 2027-09-06 08:14, 0 routes' │ 'UP since 2027-08-30 22:01, 4 routes' │ 'yes' │ 'no redundancy: 203.0.113.10 DOWN; 203.0.113.10 allows IKEv1; 203.0.113.10 waits for your device to re-initiate; 203.0.113.10 tunnel logs off' │
│ 1 │ 'eu-west-2' │ 'vpn-0b2 -> tgw-02' │ 'static' │ 'DOWN since 2027-09-07 01:00, 0 routes' │ 'DOWN since 2027-09-07 01:02, 0 routes' │ 'no' │ 'OUTAGE: every tunnel is DOWN; no TunnelState alarm' │
└─────────┴─────────────┴─────────────────────────────────┴──────────┴─────────────────────────────────────────┴─────────────────────────────────────────┴───────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Report only. No VPN, tunnel or alarm settings were changed.
exit code: 2
IDs and addresses are illustrative. vpn-0a1 still carries traffic over one tunnel, and the down tunnel is the one that waits for the customer gateway to restart IKE. vpn-0b2 is a full outage that nobody was alerted about.
What to do when an AWS VPN tunnel is down
- Turn on tunnel activity logsSite-to-Site VPN logs publish IKE, IPsec, dead peer detection and BGP details to CloudWatch Logs. AWS suggests a log group name starting with
/aws/vendedlogs/to stay under the CloudWatch Logs resource policy size limit. Set a retention period on that group too; the script to set CloudWatch log retention for all log groups does it in bulk. - Match the log message to a causeAWS documents messages such as
AWS tunnel detected a pre-shared key mismatch with cgw: xxxx,No Proposal Match Found by AWSandAWS tunnel DPD timed out after {retry_count} retransmits. A BGP session stuck inConnectpoints at TCP port 179 on your device. - Decide who starts the tunnelIf your device can’t be relied on to re-initiate, set the tunnel’s startup action to
startor the DPD timeout action torestartwithModifyVpnTunnelOptions. That tunnel is unavailable while the change applies, so do one tunnel at a time. - Add a connection-level alarmAWS’s alarm guide uses the
TunnelStatemetric perVpnId:Minimumat or below 0.5 fires when at least one tunnel is down,Maximumat or below 0 when both are.
aws cloudwatch put-metric-alarm \
--alarm-name vpn-0b2-tunnel-down \
--namespace AWS/VPN --metric-name TunnelState \
--dimensions Name=VpnId,Value=vpn-0b2 \
--statistic Minimum --period 300 --evaluation-periods 1 \
--threshold 0.5 --comparison-operator LessThanOrEqualToThreshold \
--alarm-actions arn:aws:sns:eu-west-2:123456789012:network-alerts
An alarm with no action is only a dashboard; the script to find CloudWatch alarms with no actions catches those.
How much does a Site-to-Site VPN connection cost?
| Item (US East, N. Virginia) | Price as of September 2026 |
|---|---|
| VPN connection, standard tunnels | $0.05 per connection-hour |
| VPN connection, Large Bandwidth Tunnels | $0.60 per connection-hour |
Prices come from the AWS Price List API file published 17 September 2026 and match the AWS Site-to-Site VPN pricing page. A standard connection costs 730 hours × $0.05 = $36.50 a month whether one tunnel or two is up, plus data transfer out. You’re paying for two tunnels either way, so a connection running on one tunnel is paying for redundancy it doesn’t have.
Troubleshooting
- A connection you expected isn’t listed. The script only reads connections in the
availablestate, in the Regions you pass. StatusMessageis empty. It can be empty even on a down tunnel, as in AWS’s own CLI example. Use the tunnel activity logs for the reason.- Alarm shows “no” but you have one. The script only counts alarms on
AWS/VPNTunnelStatewith aVpnIdorTunnelIpAddressdimension. Alarms built from metric math aren’t matched. UnauthorizedOperation. That’s EC2’s access denied error; the guide to troubleshoot AWS IAM access denied errors shows how to find the policy that blocks it.
When the tunnels are fine but traffic still doesn’t flow, look at the VPC side: an audit of VPC peering connections and their routes and the check to find VPCs without flow logs cover routing and visibility.
Ask ChatWithCloud instead
Ask ChatWithCloud “Are any Site-to-Site VPN tunnels down in eu-west-2?” It writes AWS SDK for JavaScript v2 code, runs it with your profile in one Region per session and explains the result. The guide to troubleshoot AWS infrastructure with an AI CLI shows follow-up questions that work well. Generated code runs without a confirmation step, so use a read-only profile.
Frequently asked questions
How do I check if an AWS VPN tunnel is down?
Run aws ec2 describe-vpn-connections --vpn-connection-ids vpn-0123 and read VgwTelemetry. Each tunnel has a Status of UP or DOWN and a LastStatusChange time.
Why is only one of my AWS VPN tunnels up?
Usually your device only has one tunnel configured, or the second tunnel failed IKE or IPsec negotiation. AWS recommends configuring both, because it updates tunnel endpoints one at a time.
What does TunnelState 0.5 mean in CloudWatch?
At the connection level, a value between 0 and 1 means at least one tunnel is not up. With two tunnels, 0.5 typically means one up and one down.
Does AWS charge for a VPN connection when the tunnel is down?
The connection-hour charge applies to each provisioned connection; as of September 2026 it’s $0.05 an hour in US East (N. Virginia). Delete connections you no longer use.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud