Find EBS Snapshots to Move to the Archive Tier

Rows of labelled archive boxes on metal shelves in a storage room

Photo by cottonbro studio on Pexels

The EBS snapshot archive tier stores a snapshot as a full copy for $0.0125 per GB-month instead of $0.05 in the standard tier (us-east-1, September 2026), with a 90-day minimum and up to 72 hours to restore. Find candidates with DescribeSnapshots: old, completed snapshots that are the only snapshot of their volume and aren’t used by an enabled AMI. Archive them with ModifySnapshotTier.

Some snapshots you can’t delete: the final copy of a decommissioned server, a quarterly compliance backup, the volume of a project that ended last year. They sit in the standard tier at full price for years. The EBS snapshot archive tier exists for exactly these, but archiving the wrong snapshot can raise your bill instead of lowering it. Where a compliance copy must also be protected from deletion, keep it in AWS Backup and use the script to check AWS Backup vaults for Vault Lock and access policies to confirm the vault really blocks deletes.

This example is for platform and FinOps engineers who want to keep old snapshots but pay less for them. The script lists every standard-tier snapshot you own that’s older than a cutoff, works out its full size, its place in the volume’s snapshot chain and what archiving would save, and only archives when you pass --apply. If a snapshot should go altogether, the scripts to find orphaned EBS snapshots whose volume is gone and clean up unused AMIs and old snapshots delete instead.

What does the EBS snapshot archive tier cost?

As of September 2026, the AWS Price List for Amazon EC2 (published 25 September 2026) shows these rates in US East (N. Virginia). Other Regions differ; check the Amazon EBS pricing page for yours.

Charge Price Usage type
Standard tier snapshot storage $0.05 per GB-month EBS:SnapshotUsage
Archive tier snapshot storage $0.0125 per GB-month EBS:SnapshotArchiveStorage
Restore from the archive tier $0.03 per GB restored EBS:SnapshotArchiveRetrieval
Deleting or permanently restoring before 90 days $0.0125 per GB-month for the remaining days EBS:SnapshotArchiveEarlyDelete

The catch is size. Standard-tier snapshots are incremental: each one stores only the blocks that changed since the previous snapshot. The EBS User Guide says an archived snapshot is always a full snapshot, containing every block written to the volume when it was taken. DescribeSnapshots reports that size as FullSnapshotSizeInBytes.

Worked example: a 500 GiB volume was deleted a year ago, and its only snapshot holds 412 GiB of data. In the standard tier it costs 412 × $0.05 = $20.60 a month. Archived, it costs 412 × $0.0125 = $5.15, a saving of $15.45 a month. The 90-day minimum commits you to 3 × $5.15 = $15.45 of archive storage, and a later restore costs 412 × $0.03 = $12.36 once, plus standard-tier storage while it’s restored.

Which snapshots should go to the archive tier?

AWS’s archiving guidelines give the rules the script applies:

  • The only snapshot of a volume is the best candidate. Its full size equals what you already pay for, so you save 75% of it.
  • Don’t archive the first snapshot in a chain. Later snapshots reference its blocks, so those blocks stay in the standard tier, now billed to the next snapshot, and you pay for the full archived copy on top.
  • Don’t archive the newest snapshot of a volume you still use. You’d need it in the standard tier to restore quickly after a failure.
  • Middle snapshots are a maybe. You only save the blocks no later snapshot references. AWS’s guide measures them by comparing ListChangedBlocks output from the EBS direct APIs, which the script doesn’t do.
  • Monthly, quarterly or yearly snapshots suit archiving; daily ones don’t. Archiving a daily chain can cost more than leaving it.

There are hard limits too. You can only archive completed snapshots you own, a snapshot used by an AMI only when every such AMI is disabled, and a shared snapshot loses its sharing when archived. You can’t create a volume from an archived snapshot, copy it or share it until you restore it, and you can’t cancel an archive or restore once it starts.

What does the script do?

  1. Lists your snapshotspaginateDescribeSnapshots with OwnerIds: ["self"], keeping standard-tier snapshots older than --older-than days (default 90).
  2. Places each snapshot in its chainGroups snapshots by VolumeId and sorts by StartTime to label each one only, first, middle or newest.
  3. Checks AMIs and volumesDescribeImages with IncludeDisabled: true finds AMIs that use the snapshot; DescribeVolumes shows whether the source volume still exists.
  4. Prices itArchive cost from FullSnapshotSizeInBytes, the monthly saving where it’s knowable, and the one-off restore cost.
  5. Archives on requestWith --apply, it skips shared snapshots and calls ModifySnapshotTier for up to --max rows marked ARCHIVE.

Because the script walks several paginated EC2 calls, the guide to paginating AWS SDK v3 list and describe calls explains the paginate* helpers it uses.

Prerequisites

  • Node.js 18 or later with tsx, and @aws-sdk/client-ec2.
  • A read-only AWS profile for the report and a separate role for --apply.
  • An idea of retention. Finding EBS volumes without recent snapshots shows which volumes rely on these snapshots as their only backup.

Which IAM permissions does it need?

ebs-snapshot-archive-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReportOnSnapshots",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeSnapshots",
        "ec2:DescribeImages",
        "ec2:DescribeVolumes",
        "ec2:DescribeSnapshotAttribute"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ArchiveOnlyWithApply",
      "Effect": "Allow",
      "Action": "ec2:ModifySnapshotTier",
      "Resource": "arn:aws:ec2:*::snapshot/*"
    }
  ]
}

Drop the second statement for a report-only role. The EBS User Guide adds kms:CreateGrant, kms:Decrypt and kms:DescribeKey for archiving and restoring encrypted snapshots, with kms:GrantIsForAWSResource as a condition on the grant. The IAM policy generator for TypeScript SDK code lists the actions if you change the calls.

The script to find EBS snapshots for the archive tier

find-ebs-snapshots-to-archive.ts

// find-ebs-snapshots-to-archive.ts
// Lists EBS snapshots you own that are older than N days and still in the standard tier, works out their
// full (archived) size and what archiving would cost, and flags the ones that are safe to archive.
// Report only by default. --apply calls ModifySnapshotTier for rows marked ARCHIVE (at most --max per run).
// Usage: npx tsx find-ebs-snapshots-to-archive.ts [--regions us-east-1,eu-west-1] [--older-than 180]
//        [--csv snapshots.csv] [--apply --max 25]
import { writeFileSync } from "node:fs";
import {
  EC2Client,
  DescribeSnapshotAttributeCommand,
  ModifySnapshotTierCommand,
  paginateDescribeImages,
  paginateDescribeSnapshots,
  paginateDescribeVolumes,
  type Snapshot,
} 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((r) => r.trim()).filter(Boolean);
const olderThan = Number(flag("--older-than") ?? 90);
const csvPath = flag("--csv");
const apply = args.includes("--apply");
const maxArchives = Number(flag("--max") ?? 25); // default quota: 25 concurrent in-progress archives per account

// us-east-1 USD from the AWS Price List (AmazonEC2), published 25 September 2026.
const STANDARD_PER_GB = 0.05; // EBS:SnapshotUsage, per GB-month
const ARCHIVE_PER_GB = 0.0125; // EBS:SnapshotArchiveStorage, per GB-month
const RESTORE_PER_GB = 0.03; // EBS:SnapshotArchiveRetrieval, per GB restored
const MIN_ARCHIVE_MONTHS = 3; // 90-day minimum archive period
const GIB = 1024 ** 3;

interface Row {
  Region: string;
  Snapshot: string;
  Volume: string;
  AgeDays: number;
  FullGiB: number;
  InLineage: string;
  AMIs: string;
  ArchivePerMonth: string;
  SavingPerMonth: string;
  RestoreCost: string;
  Verdict: string;
}

const money = (n: number): string => `$${n.toFixed(2)}`;

/** Snapshot ID -> states of the AMIs you own that reference it (disabled AMIs included). */
async function amiStates(ec2: EC2Client): Promise<Map<string, string[]>> {
  const map = new Map<string, string[]>();
  for await (const page of paginateDescribeImages({ client: ec2 }, { Owners: ["self"], IncludeDisabled: true })) {
    for (const image of page.Images ?? []) {
      for (const bdm of image.BlockDeviceMappings ?? []) {
        const id = bdm.Ebs?.SnapshotId;
        if (id) map.set(id, [...(map.get(id) ?? []), image.State ?? "unknown"]);
      }
    }
  }
  return map;
}

async function existingVolumes(ec2: EC2Client): Promise<Set<string>> {
  const ids = new Set<string>();
  for await (const page of paginateDescribeVolumes({ client: ec2 }, {})) {
    for (const v of page.Volumes ?? []) if (v.VolumeId) ids.add(v.VolumeId);
  }
  return ids;
}

async function isShared(ec2: EC2Client, snapshotId: string): Promise<boolean> {
  const out = await ec2.send(new DescribeSnapshotAttributeCommand({ SnapshotId: snapshotId, Attribute: "createVolumePermission" }));
  return (out.CreateVolumePermissions ?? []).length > 0;
}

async function scanRegion(region: string): Promise<Row[]> {
  const ec2 = new EC2Client({ region });
  const snapshots: Snapshot[] = [];
  for await (const page of paginateDescribeSnapshots({ client: ec2 }, { OwnerIds: ["self"] })) {
    snapshots.push(...(page.Snapshots ?? []));
  }
  const amis = await amiStates(ec2);
  const volumes = await existingVolumes(ec2);

  // Group every standard-tier snapshot by source volume, oldest first, to find its place in the lineage.
  const lineage = new Map<string, Snapshot[]>();
  for (const s of snapshots) {
    if (s.StorageTier === "archive") continue;
    const key = s.VolumeId ?? s.SnapshotId ?? "";
    lineage.set(key, [...(lineage.get(key) ?? []), s]);
  }
  for (const list of lineage.values()) list.sort((a, b) => (a.StartTime?.getTime() ?? 0) - (b.StartTime?.getTime() ?? 0));

  const rows: Row[] = [];
  for (const s of snapshots) {
    if (s.StorageTier === "archive") continue;
    const id = s.SnapshotId ?? "";
    const ageDays = s.StartTime ? Math.floor((Date.now() - s.StartTime.getTime()) / 86_400_000) : 0;
    if (ageDays < olderThan) continue;

    const siblings = lineage.get(s.VolumeId ?? id) ?? [s];
    const position = siblings.indexOf(s);
    const place = siblings.length === 1 ? "only" : position === 0 ? "first" : position === siblings.length - 1 ? "newest" : "middle";
    const fullGiB = s.FullSnapshotSizeInBytes !== undefined ? s.FullSnapshotSizeInBytes / GIB : s.VolumeSize ?? 0;
    const amiList = amis.get(id) ?? [];
    const volumeGone = !s.VolumeId || !volumes.has(s.VolumeId);

    let verdict: string;
    if (s.State !== "completed") verdict = `skip: ${s.State ?? "unknown"}`;
    else if (amiList.some((state) => state !== "disabled")) verdict = "keep: used by an enabled AMI";
    else if (place === "only") verdict = volumeGone ? "ARCHIVE (volume deleted)" : "ARCHIVE";
    else if (place === "newest") verdict = "keep: newest of its volume";
    else if (place === "first") verdict = "keep: first in lineage";
    else verdict = "review: check unreferenced blocks";

    rows.push({
      Region: region,
      Snapshot: id,
      Volume: s.VolumeId ?? "",
      AgeDays: ageDays,
      FullGiB: Math.round(fullGiB * 100) / 100,
      InLineage: `${place} of ${siblings.length}`,
      AMIs: amiList.length ? amiList.join("/") : "-",
      ArchivePerMonth: money(fullGiB * ARCHIVE_PER_GB),
      // Only for a volume's only snapshot is the standard-tier size known to equal the full size.
      SavingPerMonth: place === "only" ? money(fullGiB * (STANDARD_PER_GB - ARCHIVE_PER_GB)) : "unknown",
      RestoreCost: money(fullGiB * RESTORE_PER_GB),
      Verdict: verdict,
    });
  }
  return rows;
}

async function archive(rows: Row[]): Promise<void> {
  const candidates = rows.filter((r) => r.Verdict.startsWith("ARCHIVE")).slice(0, maxArchives);
  for (const r of candidates) {
    const ec2 = new EC2Client({ region: r.Region });
    try {
      if (await isShared(ec2, r.Snapshot)) {
        console.log(`${r.Snapshot}: shared with other accounts, skipped (archiving removes their access)`);
        continue;
      }
      const out = await ec2.send(new ModifySnapshotTierCommand({ SnapshotId: r.Snapshot, StorageTier: "archive" }));
      console.log(`${r.Snapshot}: archive started ${out.TieringStartTime?.toISOString() ?? ""}`);
    } catch (err) {
      console.error(`${r.Snapshot}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }
}

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> {
  if (!Number.isInteger(olderThan) || olderThan < 0) throw new Error("--older-than must be a whole number of days");
  if (!Number.isInteger(maxArchives) || maxArchives < 1) throw new Error("--max must be 1 or more");
  const rows: Row[] = [];
  for (const region of regions) {
    try {
      rows.push(...(await scanRegion(region)));
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }
  if (rows.length === 0) {
    console.log(`No standard-tier snapshots older than ${olderThan} days in ${regions.join(", ")}.`);
    return;
  }
  console.table(rows);
  const ready = rows.filter((r) => r.Verdict.startsWith("ARCHIVE"));
  const saving = ready.reduce((sum, r) => sum + Number(r.SavingPerMonth.replace("$", "")), 0);
  const minCharge = ready.reduce((sum, r) => sum + r.FullGiB * ARCHIVE_PER_GB * MIN_ARCHIVE_MONTHS, 0);
  console.log(`${ready.length} of ${rows.length} snapshots ready to archive: saves about ${money(saving)} a month ` +
    `and commits you to at least ${money(minCharge)} of archive storage (90-day minimum, us-east-1 prices).`);
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
  if (apply) await archive(rows);
  else if (ready.length) console.log("Dry run. Re-run with --apply to archive the rows marked ARCHIVE.");
}

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

How do you run it?

Terminal

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

# Report on snapshots older than 180 days in two Regions
AWS_PROFILE=readonly npx tsx find-ebs-snapshots-to-archive.ts --regions us-east-1,eu-west-1 --older-than 180 --csv archive.csv

# Archive the rows marked ARCHIVE, at most 10 this run
AWS_PROFILE=storage-admin npx tsx find-ebs-snapshots-to-archive.ts --older-than 180 --apply --max 10

Sample output

Output

┌─────────┬─────────────┬──────────────────────────┬─────────────────────────┬─────────┬─────────┬───────────────┬─────────────┬─────────────────┬────────────────┬─────────────┬─────────────────────────────────────┐
│ (index) │ Region      │ Snapshot                 │ Volume                  │ AgeDays │ FullGiB │ InLineage     │ AMIs        │ ArchivePerMonth │ SavingPerMonth │ RestoreCost │ Verdict                             │
├─────────┼─────────────┼──────────────────────────┼─────────────────────────┼─────────┼─────────┼───────────────┼─────────────┼─────────────────┼────────────────┼─────────────┼─────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'snap-0a11c0ffee0000001' │ 'vol-0aa00000000000001' │ 410     │ 412     │ 'only of 1'   │ '-'         │ '$5.15'         │ '$15.45'       │ '$12.36'    │ 'ARCHIVE (volume deleted)'          │
│ 1       │ 'us-east-1' │ 'snap-0a11c0ffee0000002' │ 'vol-0bb00000000000002' │ 300     │ 61.5    │ 'first of 4'  │ '-'         │ '$0.77'         │ 'unknown'      │ '$1.84'     │ 'keep: first in lineage'            │
│ 2       │ 'us-east-1' │ 'snap-0a11c0ffee0000003' │ 'vol-0bb00000000000002' │ 210     │ 64      │ 'middle of 4' │ '-'         │ '$0.80'         │ 'unknown'      │ '$1.92'     │ 'review: check unreferenced blocks' │
│ 3       │ 'us-east-1' │ 'snap-0a11c0ffee0000004' │ 'vol-0bb00000000000002' │ 120     │ 70      │ 'middle of 4' │ '-'         │ '$0.88'         │ 'unknown'      │ '$2.10'     │ 'review: check unreferenced blocks' │
│ 4       │ 'us-east-1' │ 'snap-0a11c0ffee0000006' │ 'vol-0cc00000000000003' │ 200     │ 8.2     │ 'only of 1'   │ 'available' │ '$0.10'         │ '$0.31'        │ '$0.25'     │ 'keep: used by an enabled AMI'      │
│ 5       │ 'us-east-1' │ 'snap-0a11c0ffee0000007' │ 'vol-0dd00000000000004' │ 365     │ 180     │ 'only of 1'   │ '-'         │ '$2.25'         │ '$6.75'        │ '$5.40'     │ 'ARCHIVE'                           │
└─────────┴─────────────┴──────────────────────────┴─────────────────────────┴─────────┴─────────┴───────────────┴─────────────┴─────────────────┴────────────────┴─────────────┴─────────────────────────────────────┘
2 of 6 snapshots ready to archive: saves about $22.20 a month and commits you to at least $22.20 of archive storage (90-day minimum, us-east-1 prices).
Dry run. Re-run with --apply to archive the rows marked ARCHIVE.

The run used mocked AWS responses, so IDs and sizes are illustrative. Two snapshots are ready: one whose volume was deleted and one that is the only snapshot of a live volume. The monthly saving and the 90-day commitment match because the saving per GB ($0.0375) is exactly three months of archive storage. One restore costs $0.03 per GB, close to a month of savings, so archive only what you expect to leave alone. The first and middle rows of a live chain stay put until someone checks their unreferenced blocks.

Troubleshooting

  • A snapshot you expected is missing. It’s younger than --older-than, already archived, or not owned by this account. Shared snapshots must be copied into your account before you can archive them.
  • “keep: used by an enabled AMI”. Deregister or disable the AMI first; the script to find public AMIs you own helps review what your images expose.
  • FullGiB equals the volume size. FullSnapshotSizeInBytes was missing, so the script fell back to VolumeSize, an upper bound.
  • Copied snapshots group oddly. The EC2 API reference says snapshots created by a copy have an arbitrary volume ID, so treat their lineage labels with care.
  • Access denied on ModifySnapshotTier. Check KMS permissions for encrypted snapshots and read how to troubleshoot AWS IAM access denied errors.
  • Only some archives start. The default quota is 25 concurrent in-progress archives per account; lower --max and run again later.

Snapshots aren’t the only place old data waits: deleting old RDS manual snapshots covers databases, and the comparison of S3 storage classes for long-term backups helps when a copy should live in S3. Before archiving anything shared, confirm it’s meant to be shared with the script to find public EBS and RDS snapshots.

Ask ChatWithCloud instead

For a one-off look, ask ChatWithCloud “Which EBS snapshots older than 180 days are the only snapshot of their volume, and how big are they?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the answer; see how ChatWithCloud runs AWS questions locally. It works in one profile and Region per session, can get things wrong and runs changes without a confirmation step, so connect it with a read-only AWS profile and archive with the script. More cleanup reports live in the AWS practical examples library.

Frequently asked questions

How long does it take to restore a snapshot from the EBS archive tier?

Up to 72 hours, depending on the snapshot’s size, according to the EBS User Guide. Use RestoreSnapshotTier with TemporaryRestoreDays for a temporary restore or PermanentRestore: true to move it back for good.

Is an archived EBS snapshot incremental?

No. Archiving converts it to a full snapshot of every block written to the volume at the time, which is why the archived copy can be larger than the incremental snapshot it came from.

What happens if I delete an archived snapshot before 90 days?

You’re billed the archive rate for the remaining days of the 90-day minimum. A temporary restore doesn’t trigger that charge; a permanent restore does.

Can I create a volume from an archived snapshot?

Not directly. Restore it to the standard tier first; CreateVolume, RunInstances, copying and sharing all need a standard-tier snapshot.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud