Find EBS Volumes Without a Recent Snapshot

A row of external hard drives lined up on a shelf with small status lights

Photo by Arina Krasnikova on Pexels

To find EBS volumes without snapshots, list in-use volumes with DescribeVolumes, list your completed snapshots with DescribeSnapshots and keep the newest StartTime per VolumeId. Volumes with no match, or only an old one, are unprotected, unless AWS Backup or a Data Lifecycle Manager policy covers them. The script below checks all three.

Most teams find out a volume had no backup at the worst possible moment: after someone ran the wrong command, or after an instance failed and its disk went with it. Snapshots are cheap insurance, but only if something actually takes them. Backup plans are set up once, tag conventions drift, and new volumes arrive without the tag the policy looks for.

This example is for engineers responsible for recovery on EC2. It’s a report: it lists every in-use volume, its newest snapshot, its last AWS Backup recovery point and the lifecycle policy that should cover it, then flags the gaps. It creates nothing, so you can run it with a read-only profile.

What counts as backup coverage for an EBS volume?

A volume can be protected in three ways, and a check that looks at only one of them reports false alarms:

Source How the script sees it Notes
EBS snapshots DescribeSnapshots with OwnerIds: self, status completed, newest StartTime per VolumeId Includes snapshots from scripts, DLM and AWS Backup. Copies of snapshots carry an arbitrary volume ID, so they don’t count for the source volume
AWS Backup ListProtectedResources, with LastBackupTime per resource ARN Lists resources with recovery points created by AWS Backup. Instance backups appear as type EC2, so the script also checks the attached instance
Data Lifecycle Manager GetLifecyclePolicies and GetLifecyclePolicy, matching target tags to volume or instance tags A default policy for volumes covers every volume in the Region unless excluded. A policy that matches but produces nothing recent is its own finding

The result is one of four statuses: ok, STALE (last backup older than --days), NEVER BACKED UP, or a policy exists but isn’t producing snapshots. The last one matters most: it’s the volume everyone believes is protected.

Why isn’t a snapshot enough on its own?

A snapshot of an attached volume captures the data written to disk when the snapshot starts, so it’s crash-consistent, not application-consistent. Databases on EBS need a flush or a pre-snapshot script. And a backup you’ve never restored is a hope, not a plan. Google’s SRE book chapter on data integrity puts it bluntly: “No one really wants to make backups; what people really want are restores.” Once this report is clean, restore one volume from each policy on a schedule: create a volume from the snapshot, attach it to a test instance and read the data.

What does the script do?

  1. Indexes snapshotsOne paginated DescribeSnapshots call per Region, keeping the newest completed snapshot per volume.
  2. Reads AWS BackuppaginateListProtectedResources, splitting EBS volume and EC2 instance recovery points.
  3. Reads DLM policiesEnabled snapshot and AMI policies, their target tags, and whether a default policy for volumes exists. Instance tags are fetched only if a policy targets instances.
  4. Checks each volumepaginateDescribeVolumes for in-use volumes (add --all to include detached ones), then compares the newest backup with --days.
  5. Reports onlyIt prints the gaps, the total GiB unprotected, and writes a CSV with --csv.

Prerequisites

  • Node.js 18 or later, npm and tsx, plus @aws-sdk/client-ec2, @aws-sdk/client-backup and @aws-sdk/client-dlm.
  • A read-only AWS profile. The whole report uses describe and list calls.
  • A decision on your recovery point objective. --days 1 for daily backups, --days 7 for weekly.

Which IAM permissions does it need?

ebs-backup-coverage-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadVolumesAndSnapshots",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeVolumes",
        "ec2:DescribeSnapshots",
        "ec2:DescribeInstances",
        "backup:ListProtectedResources",
        "dlm:GetLifecyclePolicies"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ReadLifecyclePolicyDetails",
      "Effect": "Allow",
      "Action": "dlm:GetLifecyclePolicy",
      "Resource": "arn:aws:dlm:*:123456789012:policy/*"
    }
  ]
}

To check the list against the code, paste the script into the free IAM policy generator for TypeScript code. If AWS Backup returns an access error in a Region you don’t use, the guide to troubleshoot IAM access denied errors step by step helps you tell a missing permission from a service control policy.

The script to find EBS volumes without snapshots

find-ebs-volumes-without-recent-snapshots.ts

// find-ebs-volumes-without-recent-snapshots.ts
// Report only. For every in-use EBS volume, finds its newest completed snapshot, its last AWS Backup
// recovery point, and whether a Data Lifecycle Manager policy targets it, then flags volumes with no
// backup newer than --days.
// Usage:
//   npx tsx find-ebs-volumes-without-recent-snapshots.ts [--days 7] [--regions us-east-1,eu-west-1] [--all] [--csv ebs-backups.csv]
import { writeFileSync } from "node:fs";
import { BackupClient, paginateListProtectedResources } from "@aws-sdk/client-backup";
import { DLMClient, GetLifecyclePoliciesCommand, GetLifecyclePolicyCommand } from "@aws-sdk/client-dlm";
import { EC2Client, paginateDescribeInstances, paginateDescribeSnapshots, paginateDescribeVolumes, type Tag } 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 days = Number(flag("--days") ?? "7");
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const includeAvailable = args.includes("--all"); // also report detached volumes
const csvPath = flag("--csv");
const DAY = 86_400_000;

interface Row {
  Region: string;
  Volume: string;
  Name: string;
  Instance: string;
  GiB: number;
  LastSnapshot: string;
  LastBackup: string;
  AgeDays: number | string;
  Policy: string;
  Status: string;
}

interface DlmCoverage {
  defaultPolicy: string | undefined; // ID of an enabled default policy for volumes, if any
  tagged: { id: string; tags: Tag[] }[]; // enabled target-tag policies for volumes
  instanceTagged: { id: string; tags: Tag[] }[]; // enabled policies that snapshot whole instances
}

async function dlmCoverage(region: string): Promise<DlmCoverage> {
  const dlm = new DLMClient({ region });
  const cover: DlmCoverage = { defaultPolicy: undefined, tagged: [], instanceTagged: [] };
  const { Policies = [] } = await dlm.send(new GetLifecyclePoliciesCommand({ State: "ENABLED" }));
  for (const summary of Policies) {
    if (summary.PolicyType !== "EBS_SNAPSHOT_MANAGEMENT" && summary.PolicyType !== "IMAGE_MANAGEMENT") continue;
    const { Policy } = await dlm.send(new GetLifecyclePolicyCommand({ PolicyId: summary.PolicyId }));
    const d = Policy?.PolicyDetails;
    const id = summary.PolicyId ?? "";
    if (summary.DefaultPolicy && d?.ResourceType === "VOLUME") cover.defaultPolicy = id;
    else if (d?.ResourceTypes?.includes("VOLUME")) cover.tagged.push({ id, tags: d.TargetTags ?? [] });
    else if (d?.ResourceTypes?.includes("INSTANCE")) cover.instanceTagged.push({ id, tags: d.TargetTags ?? [] });
  }
  return cover;
}

const matches = (have: Tag[], want: Tag[]) => want.some((w) => have.some((h) => h.Key === w.Key && h.Value === w.Value));

async function scanRegion(region: string): Promise<Row[]> {
  const ec2 = new EC2Client({ region });

  // Newest completed snapshot per volume. Copied snapshots and AMI copies carry vol-ffffffff, not the source.
  const newest = new Map<string, Date>();
  for await (const page of paginateDescribeSnapshots({ client: ec2 }, { OwnerIds: ["self"], Filters: [{ Name: "status", Values: ["completed"] }] })) {
    for (const s of page.Snapshots ?? []) {
      if (!s.VolumeId || !s.StartTime) continue;
      const seen = newest.get(s.VolumeId);
      if (!seen || s.StartTime > seen) newest.set(s.VolumeId, s.StartTime);
    }
  }

  // AWS Backup: last recovery point for each protected EBS volume (EC2 instance backups are listed as EC2).
  const backups = new Map<string, Date>();
  const instanceBackups = new Map<string, Date>();
  for await (const page of paginateListProtectedResources({ client: new BackupClient({ region }) }, {})) {
    for (const r of page.Results ?? []) {
      const id = r.ResourceArn?.split("/").pop() ?? "";
      if (!r.LastBackupTime) continue;
      if (r.ResourceType === "EBS") backups.set(id, r.LastBackupTime);
      if (r.ResourceType === "EC2") instanceBackups.set(id, r.LastBackupTime);
    }
  }

  const dlm = await dlmCoverage(region);
  // Instance tags, only needed when a DLM policy targets instances rather than volumes.
  const instanceTags = new Map<string, Tag[]>();
  if (dlm.instanceTagged.length) {
    for await (const page of paginateDescribeInstances({ client: ec2 }, {})) {
      for (const r of page.Reservations ?? []) for (const i of r.Instances ?? []) instanceTags.set(i.InstanceId ?? "", i.Tags ?? []);
    }
  }
  const rows: Row[] = [];
  const states = includeAvailable ? ["in-use", "available"] : ["in-use"];
  for await (const page of paginateDescribeVolumes({ client: ec2 }, { Filters: [{ Name: "status", Values: states }] })) {
    for (const v of page.Volumes ?? []) {
      const id = v.VolumeId ?? "";
      const instance = v.Attachments?.[0]?.InstanceId ?? "";
      const tags = v.Tags ?? [];
      const snap = newest.get(id);
      const backup = backups.get(id) ?? (instance ? instanceBackups.get(instance) : undefined);
      const latest = [snap, backup].filter((d): d is Date => d !== undefined).sort((a, b) => b.getTime() - a.getTime())[0];
      const age = latest ? Math.floor((Date.now() - latest.getTime()) / DAY) : undefined;

      const policy =
        dlm.tagged.find((p) => matches(tags, p.tags))?.id ??
        dlm.instanceTagged.find((p) => matches(instanceTags.get(instance) ?? [], p.tags))?.id ??
        (dlm.defaultPolicy ? `${dlm.defaultPolicy} (default)` : "");

      let status = "ok";
      if (age === undefined) status = policy ? "NO SNAPSHOT YET (policy set)" : "NEVER BACKED UP";
      else if (age > days) status = policy ? "STALE (policy not producing)" : `STALE (> ${days} days)`;

      rows.push({
        Region: region,
        Volume: id,
        Name: tags.find((t) => t.Key === "Name")?.Value ?? "",
        Instance: instance,
        GiB: v.Size ?? 0,
        LastSnapshot: snap ? snap.toISOString().slice(0, 10) : "",
        LastBackup: backup ? backup.toISOString().slice(0, 10) : "",
        AgeDays: age ?? "",
        Policy: policy,
        Status: status,
      });
    }
  }
  return rows;
}

function toCsv(rows: Row[]): string {
  const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
  const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
  return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of regions) rows.push(...(await scanRegion(region)));
  const problems = rows.filter((r) => r.Status !== "ok");
  console.table(problems.length ? problems : rows);

  const gib = problems.reduce((n, r) => n + r.GiB, 0);
  console.log(`${rows.length} volumes checked, ${problems.length} without a backup in the last ${days} days (${gib} GiB)`);
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
  console.log("Report only: no snapshot or policy was created.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-ec2 @aws-sdk/client-backup @aws-sdk/client-dlm
npm install --save-dev tsx typescript @types/node

# Daily RPO, two Regions, CSV for the ticket
AWS_PROFILE=readonly npx tsx find-ebs-volumes-without-recent-snapshots.ts --days 1 --regions us-east-1,eu-west-1 --csv ebs-backups.csv

Sample output

Output

┌─────────┬─────────────┬─────────────────────────┬──────────────┬───────────────────────┬─────┬──────────────┬──────────────┬─────────┬──────────────────────────┬────────────────────────────────┐
│ (index) │ Region      │ Volume                  │ Name         │ Instance              │ GiB │ LastSnapshot │ LastBackup   │ AgeDays │ Policy                   │ Status                         │
├─────────┼─────────────┼─────────────────────────┼──────────────┼───────────────────────┼─────┼──────────────┼──────────────┼─────────┼──────────────────────────┼────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'vol-0a1b2c3d4e5f60718' │ 'jenkins'    │ 'i-0123456789abcdef0' │ 500 │ ''           │ ''           │ ''      │ ''                       │ 'NEVER BACKED UP'              │
│ 1       │ 'us-east-1' │ 'vol-0b2c3d4e5f6071829' │ 'pg-data'    │ 'i-0fedcba9876543210' │ 200 │ '2026-08-02' │ ''           │ 56      │ 'policy-0a1b2c3d4e5f6a7b8' │ 'STALE (policy not producing)' │
│ 2       │ 'eu-west-1' │ 'vol-0c3d4e5f607182930' │ 'wiki-data'  │ 'i-0a0b0c0d0e0f01020' │ 100 │ '2026-09-12' │ '2026-09-12' │ 15      │ ''                       │ 'STALE (> 1 days)'             │
└─────────┴─────────────┴─────────────────────────┴──────────────┴───────────────────────┴─────┴──────────────┴──────────────┴─────────┴──────────────────────────┴────────────────────────────────┘
38 volumes checked, 3 without a backup in the last 1 days (800 GiB)
Report only: no snapshot or policy was created.

IDs are illustrative. pg-data is the dangerous row: its volume carries the tag the policy targets, but the last snapshot is 56 days old. Check the policy’s state and its IAM role; a policy in the ERROR state stops creating snapshots without any alarm unless you set one up. wiki-data was backed up once by an on-demand AWS Backup job and never added to a plan.

How do you fix the gaps?

  1. Take a snapshot nowFor each NEVER BACKED UP volume that matters: aws ec2 create-snapshot --volume-id vol-0a1b2c3d4e5f60718 --description "manual before policy".
  2. Add a safety netA DLM default policy for volumes snapshots every volume in the Region that has no backup from any other source within its creation interval (1 to 7 days), and keeps them 2 to 14 days: aws dlm create-lifecycle-policy --state ENABLED --description "Default volume policy" --execution-role-arn arn:aws:iam::123456789012:role/AWSDataLifecycleManagerDefaultRole --default-policy VOLUME. It skips volumes less than 24 hours old.
  3. Fix the targeted policiesFor stale volumes with a matching policy, check the policy state and role, then tag new volumes consistently; the script to find untagged AWS resources with the tagging API shows which volumes miss the backup tag.
  4. Test a restoreCreate a volume from the newest snapshot in another Availability Zone and mount it. Until then, the report only proves snapshots exist.

More snapshots mean more storage, so keep retention in check. Snapshots whose volume is gone are handled by the script to find orphaned EBS snapshots whose volume is gone, and old AMIs by the one to clean up AMIs and snapshots older than 30 days. Also make sure new snapshots stay private with the check to find public EBS and RDS snapshots, and are encrypted, which follows from the script to find unencrypted EBS volumes and turn on default encryption.

Troubleshooting

  • A volume shows as never backed up, but you know it has snapshots. They may be copies, which carry an arbitrary volume ID instead of the source’s, or they were taken in another account. The script only counts snapshots your account owns in the same Region.
  • AWS Backup shows the instance but not the volume. Instance backups create an AMI and its snapshots and are listed as EC2. The script uses the instance’s time for attached volumes.
  • The script is slow. Accounts with tens of thousands of snapshots page through DescribeSnapshots for a while. Run it per Region with --regions.
  • AccessDeniedException from DLM or Backup. Add the actions above; a service control policy can also block a Region you don’t use.

Ask ChatWithCloud instead

For a quick answer, ask ChatWithCloud “Which in-use EBS volumes in us-east-1 have no snapshot from the last 7 days?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and explains the result; how ChatWithCloud runs AWS SDK code on your machine covers the details. It won’t know about DLM or AWS Backup unless you ask, and it runs changes without a confirmation step, so connect ChatWithCloud through a read-only AWS profile.

Frequently asked questions

How do I find EBS volumes without snapshots in the AWS CLI?

List snapshot volume IDs with aws ec2 describe-snapshots --owner-ids self --query "Snapshots[].VolumeId", list volumes with aws ec2 describe-volumes --query "Volumes[].VolumeId", and compare the two lists. The script above also checks dates, AWS Backup and DLM.

Does AWS back up EBS volumes automatically?

No. You need a Data Lifecycle Manager policy, an AWS Backup plan or your own snapshots. A DLM default policy is the simplest way to cover every volume in a Region.

Can I snapshot an EBS volume while it’s in use?

Yes. The snapshot captures data already written to the volume when it starts, so it’s crash-consistent. For databases, flush or freeze writes first for an application-consistent copy.

How often should EBS volumes be snapshotted?

As often as the data you can afford to lose: daily for most application data, more often for databases without their own backups. Snapshots are incremental, so frequent ones mostly cost the changed blocks.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud