Find Resources Not Protected by AWS Backup (Coverage Report)

Rows of hard drive bays in a storage server rack with green status lights

Photo by panumas nikhomkhai on Pexels

An AWS Backup coverage report compares what you run with what AWS Backup has actually backed up. List your EC2 instances, EBS volumes, RDS and Aurora databases, DynamoDB tables and EFS file systems, then call ListProtectedResources, which returns every resource with an AWS Backup recovery point and its LastBackupTime. Anything missing, or older than your target, is a gap.

A backup plan that exists isn’t the same as a resource that’s backed up. Tag-based assignments miss resources nobody tagged, a service can be opted out in the Region, and a job can fail quietly for weeks. The only reliable test is to look at the recovery points.

This example is for engineers who own backup policy. You’ll get a script that prints each backup plan’s resource assignments, then an AWS Backup coverage report for six resource types: never backed up, stale beyond --days, or blocked by the Region’s opt-in setting. It’s report only. For EBS alone, the script to find EBS volumes without a recent snapshot also counts snapshots taken outside AWS Backup.

What counts as protected in an AWS Backup coverage report?

ListProtectedResources returns resources that have recovery points created by AWS Backup, with the time of the last backup, the vault it went to and the resource type. That definition has consequences:

  • Native backups don’t count. RDS automated backups, DynamoDB point-in-time recovery and snapshots from your own scripts aren’t AWS Backup recovery points. A database can be well protected and still show up here as a gap. Check those with the audit of RDS instances without automated backups and the script to enable DynamoDB point-in-time recovery on every table.
  • Status isn’t checked. A resource appears once it has a recovery point, whatever that recovery point’s status. A recent LastBackupTime is the better signal, which is why the script ages every entry.
  • Instance backups cover attached volumes. AWS Backup backs up the EBS volumes attached to an EC2 instance together, as crash-consistent snapshots. The script marks those volumes OK (via instance) instead of listing them as gaps.
  • Opt-in comes first. DescribeRegionSettings returns a per-service opt-in map. If a service is opted out, AWS Backup doesn’t try to protect its resources in that Region, even when a plan selects them.

How do backup plans pick resources?

Each plan has one or more resource assignments, returned by ListBackupSelections and GetBackupSelection. An assignment lists ARNs in Resources, exclusions in NotResources, and tag rules in Conditions (AND logic) or the older ListOfTags (OR logic). With no conditions, tags or ARNs, AWS Backup tries to select every supported, opted-in resource, which can cost more than you planned. Up to 500 ARNs fit in a list without wildcards, or 30 with them. The script prints these so you can see why a resource was or wasn’t picked up; the untagged AWS resources report finds the resources a tag rule will never match.

What does the script do?

  1. Reads the Region settingsDescribeRegionSettings for the opt-in map, and GetCallerIdentity for the account and partition used to build ARNs.
  2. Prints plan assignmentspaginateListBackupPlans, paginateListBackupSelections and GetBackupSelection per assignment: listed ARNs, excluded ARNs and tag rules.
  3. Loads recovery pointspaginateListProtectedResources into a map of resource ARN to LastBackupTime, and counts vaults with Vault Lock from paginateListBackupVaults.
  4. Builds the inventoryDescribeInstances, DescribeVolumes, DescribeDBInstances (instances outside a cluster), DescribeDBClusters (Aurora engines only), ListTables and DescribeFileSystems.
  5. Reports gapsEvery resource gets OK, STALE, NO BACKUP or OPT-IN OFF, and only the gaps are printed.

Prerequisites

  • Node.js 18 or later with tsx, plus @aws-sdk/client-backup, client-sts, client-ec2, client-rds, client-dynamodb and client-efs.
  • A read-only profile per Region. Every list call uses a paginator; the guide to paginating any AWS API with SDK v3 paginators explains the pattern.
  • Your recovery point objective in days, for --days. The default of 2 suits daily plans.

Which IAM permissions does it need?

All read-only. Backup plan assignments are scoped to backup plan ARNs; the list and describe calls need "*". GetCallerIdentity needs no permission.

backup-coverage-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BackupReadOnly",
      "Effect": "Allow",
      "Action": [
        "backup:DescribeRegionSettings",
        "backup:ListBackupPlans",
        "backup:ListBackupVaults",
        "backup:ListProtectedResources"
      ],
      "Resource": "*"
    },
    {
      "Sid": "BackupPlanAssignments",
      "Effect": "Allow",
      "Action": ["backup:ListBackupSelections", "backup:GetBackupSelection"],
      "Resource": "arn:aws:backup:*:111122223333:backup-plan:*"
    },
    {
      "Sid": "Inventory",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeVolumes",
        "rds:DescribeDBInstances",
        "rds:DescribeDBClusters",
        "dynamodb:ListTables",
        "elasticfilesystem:DescribeFileSystems"
      ],
      "Resource": "*"
    }
  ]
}

The script for an AWS Backup coverage report

backup-coverage-report.ts

// backup-coverage-report.ts
// AWS Backup coverage report for one Region: lists backup plans and their resource assignments, then compares
// EC2, EBS, RDS, Aurora, DynamoDB and EFS resources against ListProtectedResources to find resources with no
// AWS Backup recovery point, or none in the last N days. Report only: it changes nothing.
// Usage: npx tsx backup-coverage-report.ts [--region us-east-1] [--days 2]
import {
  BackupClient,
  DescribeRegionSettingsCommand,
  GetBackupSelectionCommand,
  paginateListBackupPlans,
  paginateListBackupSelections,
  paginateListBackupVaults,
  paginateListProtectedResources,
} from "@aws-sdk/client-backup";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
import { EC2Client, paginateDescribeInstances, paginateDescribeVolumes } from "@aws-sdk/client-ec2";
import { RDSClient, paginateDescribeDBClusters, paginateDescribeDBInstances } from "@aws-sdk/client-rds";
import { DynamoDBClient, paginateListTables } from "@aws-sdk/client-dynamodb";
import { EFSClient, paginateDescribeFileSystems } from "@aws-sdk/client-efs";

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 region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const days = Number(flag("--days") ?? "2");
const DAY_MS = 86_400_000;

const backup = new BackupClient({ region });
const ec2 = new EC2Client({ region });
const rds = new RDSClient({ region });
const ddb = new DynamoDBClient({ region });
const efs = new EFSClient({ region });

interface Resource {
  type: string; // AWS Backup resource type: EC2, EBS, RDS, Aurora, DynamoDB, EFS
  arn: string;
  name: string;
  attachedTo?: string; // instance ARN, for EBS volumes
}

interface Row {
  Type: string;
  Resource: string;
  Status: string;
  LastBackup: string;
  AgeDays: number | string;
}

async function inventory(partition: string, account: string): Promise<Resource[]> {
  const out: Resource[] = [];
  const ec2Arn = (kind: string, id: string) => `arn:${partition}:ec2:${region}:${account}:${kind}/${id}`;
  for await (const page of paginateDescribeInstances({ client: ec2 }, {})) {
    for (const r of page.Reservations ?? []) {
      for (const i of r.Instances ?? []) {
        if (!i.InstanceId || i.State?.Name === "terminated") continue;
        const name = i.Tags?.find((t) => t.Key === "Name")?.Value ?? i.InstanceId;
        out.push({ type: "EC2", arn: ec2Arn("instance", i.InstanceId), name });
      }
    }
  }
  for await (const page of paginateDescribeVolumes({ client: ec2 }, {})) {
    for (const v of page.Volumes ?? []) {
      if (!v.VolumeId) continue;
      const instanceId = v.Attachments?.[0]?.InstanceId;
      out.push({
        type: "EBS",
        arn: ec2Arn("volume", v.VolumeId),
        name: v.VolumeId,
        attachedTo: instanceId ? ec2Arn("instance", instanceId) : undefined,
      });
    }
  }
  for await (const page of paginateDescribeDBInstances({ client: rds }, {})) {
    for (const db of page.DBInstances ?? []) {
      // Aurora (and DocumentDB/Neptune) instances belong to a cluster; AWS Backup protects the cluster.
      if (db.DBClusterIdentifier || !db.DBInstanceArn) continue;
      out.push({ type: "RDS", arn: db.DBInstanceArn, name: db.DBInstanceIdentifier ?? db.DBInstanceArn });
    }
  }
  for await (const page of paginateDescribeDBClusters({ client: rds }, {})) {
    for (const c of page.DBClusters ?? []) {
      if (!c.DBClusterArn || !c.Engine?.startsWith("aurora")) continue;
      out.push({ type: "Aurora", arn: c.DBClusterArn, name: c.DBClusterIdentifier ?? c.DBClusterArn });
    }
  }
  for await (const page of paginateListTables({ client: ddb }, {})) {
    for (const t of page.TableNames ?? []) {
      out.push({ type: "DynamoDB", arn: `arn:${partition}:dynamodb:${region}:${account}:table/${t}`, name: t });
    }
  }
  for await (const page of paginateDescribeFileSystems({ client: efs }, {})) {
    for (const fs of page.FileSystems ?? []) {
      if (!fs.FileSystemArn) continue;
      out.push({ type: "EFS", arn: fs.FileSystemArn, name: fs.Name ?? fs.FileSystemId ?? fs.FileSystemArn });
    }
  }
  return out;
}

async function printPlans(): Promise<void> {
  const rows: { Plan: string; Selection: string; Resources: string; Excluded: number; TagRules: number }[] = [];
  for await (const page of paginateListBackupPlans({ client: backup }, {})) {
    for (const plan of page.BackupPlansList ?? []) {
      if (!plan.BackupPlanId) continue;
      for await (const sel of paginateListBackupSelections({ client: backup }, { BackupPlanId: plan.BackupPlanId })) {
        for (const s of sel.BackupSelectionsList ?? []) {
          if (!s.SelectionId) continue;
          const detail = await backup.send(
            new GetBackupSelectionCommand({ BackupPlanId: plan.BackupPlanId, SelectionId: s.SelectionId }),
          );
          const bs = detail.BackupSelection;
          const c = bs?.Conditions;
          const tagRules =
            (bs?.ListOfTags?.length ?? 0) +
            (c?.StringEquals?.length ?? 0) +
            (c?.StringLike?.length ?? 0) +
            (c?.StringNotEquals?.length ?? 0) +
            (c?.StringNotLike?.length ?? 0);
          rows.push({
            Plan: plan.BackupPlanName ?? plan.BackupPlanId,
            Selection: bs?.SelectionName ?? s.SelectionId,
            Resources: (bs?.Resources ?? []).join(", ") || "(none listed)",
            Excluded: bs?.NotResources?.length ?? 0,
            TagRules: tagRules,
          });
        }
      }
    }
  }
  console.log(`Backup plans and resource assignments in ${region}:`);
  if (rows.length) console.table(rows);
  else console.log("  No backup plans with resource assignments. Nothing is backed up on a schedule.");
}

async function main(): Promise<void> {
  const me = await new STSClient({ region }).send(new GetCallerIdentityCommand({}));
  const partition = me.Arn?.split(":")[1] ?? "aws";
  const account = me.Account ?? "";

  const settings = await backup.send(new DescribeRegionSettingsCommand({}));
  const optIn = settings.ResourceTypeOptInPreference ?? {};
  await printPlans();

  const protectedByArn = new Map<string, Date | undefined>();
  for await (const page of paginateListProtectedResources({ client: backup }, {})) {
    for (const p of page.Results ?? []) if (p.ResourceArn) protectedByArn.set(p.ResourceArn, p.LastBackupTime);
  }

  let vaults = 0;
  let locked = 0;
  for await (const page of paginateListBackupVaults({ client: backup }, {})) {
    for (const v of page.BackupVaultList ?? []) {
      vaults++;
      if (v.Locked) locked++;
    }
  }

  const now = Date.now();
  const rows: Row[] = [];
  for (const r of await inventory(partition, account)) {
    let last = protectedByArn.get(r.arn);
    let via = "";
    if (!last && r.attachedTo && protectedByArn.get(r.attachedTo)) {
      last = protectedByArn.get(r.attachedTo);
      via = " (via instance)";
    }
    const age = last ? Math.floor((now - last.getTime()) / DAY_MS) : undefined;
    let status: string;
    if (optIn[r.type] === false) status = "OPT-IN OFF";
    else if (age === undefined) status = "NO BACKUP";
    else if (age > days) status = "STALE";
    else status = "OK";
    rows.push({
      Type: r.type,
      Resource: r.name,
      Status: status + via,
      LastBackup: last ? last.toISOString().slice(0, 16).replace("T", " ") : "never",
      AgeDays: age ?? "-",
    });
  }
  const gaps = rows.filter((r) => !r.Status.startsWith("OK"));
  console.log(`Resources in ${region} without an AWS Backup recovery point in the last ${days} days:`);
  console.table(gaps);
  console.log(
    `${rows.length} resources checked: ${rows.length - gaps.length} covered, ${gaps.length} gaps. ` +
      `Backup vaults: ${vaults}, with Vault Lock: ${locked}. Report only: nothing was changed.`,
  );
}

main().catch((err) => {
  console.error(err instanceof Error ? `${err.name}: ${err.message}` : String(err));
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-backup @aws-sdk/client-sts @aws-sdk/client-ec2 \
  @aws-sdk/client-rds @aws-sdk/client-dynamodb @aws-sdk/client-efs
npm install --save-dev tsx typescript @types/node

AWS_PROFILE=readonly npx tsx backup-coverage-report.ts --region us-east-1 --days 2

# Weekly plans: allow 8 days before a resource counts as stale
AWS_PROFILE=readonly npx tsx backup-coverage-report.ts --region eu-west-1 --days 8

Sample output

Output

Backup plans and resource assignments in us-east-1:
┌─────────┬─────────────┬───────────────────────┬───────────┬──────────┬──────────┐
│ (index) │ Plan        │ Selection             │ Resources │ Excluded │ TagRules │
├─────────┼─────────────┼───────────────────────┼───────────┼──────────┼──────────┤
│ 0       │ 'daily-35d' │ 'tagged-backup-daily' │ '*'       │ 0        │ 1        │
└─────────┴─────────────┴───────────────────────┴───────────┴──────────┴──────────┘
Resources in us-east-1 without an AWS Backup recovery point in the last 2 days:
┌─────────┬────────────┬───────────────┬──────────────┬────────────────────┬─────────┐
│ (index) │ Type       │ Resource      │ Status       │ LastBackup         │ AgeDays │
├─────────┼────────────┼───────────────┼──────────────┼────────────────────┼─────────┤
│ 0       │ 'EC2'      │ 'i-0b2'       │ 'NO BACKUP'  │ 'never'            │ '-'     │
│ 1       │ 'EBS'      │ 'vol-02'      │ 'NO BACKUP'  │ 'never'            │ '-'     │
│ 2       │ 'RDS'      │ 'orders-db'   │ 'STALE'      │ '2026-09-19 15:41' │ 9       │
│ 3       │ 'Aurora'   │ 'analytics'   │ 'NO BACKUP'  │ 'never'            │ '-'     │
│ 4       │ 'DynamoDB' │ 'audit-log'   │ 'NO BACKUP'  │ 'never'            │ '-'     │
│ 5       │ 'EFS'      │ 'shared-home' │ 'OPT-IN OFF' │ 'never'            │ '-'     │
└─────────┴────────────┴───────────────┴──────────────┴────────────────────┴─────────┘
9 resources checked: 3 covered, 6 gaps. Backup vaults: 2, with Vault Lock: 1. Report only: nothing was changed.

Names and dates are illustrative. The only plan selects by the tag backup=daily, so the stopped instance i-0b2, the detached volume vol-02, the analytics Aurora cluster and the audit-log table were never tagged. orders-db was last backed up 9 days ago: the tag is there, so look at its failed backup jobs. EFS is opted out in this Region, so shared-home won’t be backed up until you turn the opt-in back on. vol-01 doesn’t appear because its instance, web-1, is backed up.

How do you close the gaps?

  • Untagged resources: add the tag your plan selects on, or add an assignment for the resource type. Tagging is cheaper to maintain than ARN lists.
  • Stale resources: look at the backup jobs for that resource ARN; a failed or expired job tells you more than the plan does.
  • Opted-out services: turn the service back on in the Region settings, then wait for the next scheduled window.
  • Resources you don’t need: delete them rather than back them up. Old copies cost money too; see the guide to backup storage class costs. EBS snapshots taken outside AWS Backup that you must keep for months can move to the archive tier; the script to find EBS snapshots to move to the archive tier lists candidates.

To keep this checked continuously, AWS Backup Audit Manager has controls for exactly these two questions: resources are included in at least one backup plan and last recovery point was created within a time frame. The AWS Backup Audit Manager controls reference lists their parameters. For backups that must survive a compromised account, a vault with Vault Lock blocks delete and update operations on its recovery points; the script prints how many of your vaults have it, and the script to check AWS Backup vaults for Vault Lock and access policies shows the lock mode, retention limits and deny policies of each vault.

Troubleshooting

  • A database appears twice or not at all. Aurora is protected at cluster level, so the script skips cluster member instances and non-Aurora clusters (DocumentDB and Neptune also appear in DescribeDBClusters). RDS Multi-AZ DB clusters aren’t covered here.
  • Everything shows NO BACKUP. Check the Region: recovery points and plans are per Region. Also check that the profile’s account is the one that owns the resources, not a central backup account.
  • Audit Manager and the script disagree. Audit Manager controls only evaluate active resources, so a stopped EC2 instance isn’t included there. The script lists stopped instances on purpose: they still hold data.
  • AccessDeniedException on an inventory call. The policy above is missing an action for that service; the guide to troubleshooting IAM access denied errors step by step shows how to find it.

Ask ChatWithCloud instead

For a one-off answer, ask ChatWithCloud “Which RDS databases and DynamoDB tables in us-east-1 have no AWS Backup recovery point in the last 2 days?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and summarizes the resources it found. It can be wrong, so check anything you’ll act on, and use a read-only profile, as the how ChatWithCloud works overview explains.

Frequently asked questions

How do I find resources not backed up by AWS Backup?

List the resources you run, call ListProtectedResources, and compare ARNs. Resources that aren’t in the protected list, or whose LastBackupTime is older than your target, aren’t covered.

Does ListProtectedResources include RDS automated backups?

No. It only returns resources with recovery points created by AWS Backup. Native RDS automated backups and DynamoDB point-in-time recovery are separate features.

Does AWS Backup have a built-in coverage report?

AWS Backup Audit Manager has controls that check whether resources are in a backup plan and whether a recovery point was created within a time frame, and it can produce reports from them.

Why is a resource in my backup plan not being backed up?

The usual causes are a tag rule it doesn’t match, the service being opted out in the Region, or failing backup jobs. Check the assignment, DescribeRegionSettings and the job history.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud