Find RDS Instances Without Automated Backups or Encryption

A row of hard disk drives mounted in a storage array with blue indicator lights

Photo by William Warby on Unsplash

RDS automated backups are disabled when a DB instance’s BackupRetentionPeriod is 0. Call DescribeDBInstances in each Region and flag those instances, along with StorageEncrypted false and DeletionProtection false. Turn backups back on with ModifyDBInstance and a retention of 1 to 35 days. Aurora can’t disable automated backups, so check its retention length instead.

An RDS instance with automated backups disabled has no point-in-time recovery. If someone drops a table or the instance is deleted without a final snapshot, there is nothing to restore from. It usually happens on purpose once, to speed up a bulk load or save a little on a test database, and then the setting outlives the reason.

This example gives you a TypeScript script for the AWS SDK for JavaScript v3 that finds instances with RDS automated backups disabled in every Region, and in the same pass reports unencrypted storage and missing deletion protection. It only reads unless you pass --apply, like the rest of the AWS SDK v3 practical examples.

Which three settings protect an RDS database?

  • Automated backups (BackupRetentionPeriod). A positive number of days turns them on, 0 turns them off, and the maximum is 35. RDS snapshots the storage volume of the whole instance during the backup window, and you can restore to any point in time within the retention period.
  • Encryption at rest (StorageEncrypted). Covers the storage, logs, automated backups, read replicas and snapshots, using an AWS KMS key.
  • Deletion protection (DeletionProtection). While it’s on, the database can’t be deleted. It’s off by default for DB instances.

Backups matter most, because the other two don’t help you after a mistake. CISA’s #StopRansomware Guide recommends offline, encrypted backups of critical data and regular tests of their availability and integrity. Encrypted automated backups cover part of that inside AWS; the offline copy and the restore test are still on you.

What does the script do?

  1. Lists RegionsDescribeRegions, or the list you pass with --regions=.
  2. Reads DB clustersDescribeDBClusters returns Aurora and Multi-AZ DB clusters, whose retention, encryption and deletion protection are set on the cluster.
  3. Reads standalone DB instancesDescribeDBInstances, skipping members of a cluster (already covered) and marking read replicas separately.
  4. Flags the gapsNo automated backups, retention shorter than --days (default 7), unencrypted storage, and deletion protection off.
  5. Turns on backups only on requestWith --apply, ModifyDBInstance sets BackupRetentionPeriod on standalone instances that have none. It never touches encryption or deletion protection.

Prerequisites

Which IAM permissions does it need?

Three read actions for the report, plus rds:ModifyDBInstance for --apply. Replace 123456789012 with your account ID, and drop the second statement for an audit-only role.

rds-backup-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadBackupSettings",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "rds:DescribeDBInstances",
        "rds:DescribeDBClusters"
      ],
      "Resource": "*"
    },
    {
      "Sid": "EnableBackupsWithApply",
      "Effect": "Allow",
      "Action": "rds:ModifyDBInstance",
      "Resource": "arn:aws:rds:*:123456789012:db:*"
    }
  ]
}

The free IAM policy generator for TypeScript code produces a starting point like this if you extend the script.

The script to find RDS instances with automated backups disabled

find-rds-instances-without-automated-backups.ts

// find-rds-instances-without-automated-backups.ts
// Reports, per Region, RDS DB instances and DB clusters with automated backups disabled
// (BackupRetentionPeriod 0), short retention, unencrypted storage or deletion protection off.
// Report-only by default. --apply turns on automated backups (BackupRetentionPeriod = --days)
// for standalone instances that have none; add --now to apply outside the maintenance window.
// Usage: npx tsx find-rds-instances-without-automated-backups.ts [--regions=us-east-1] [--days=7] [--apply [--now]]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import {
  RDSClient,
  ModifyDBInstanceCommand,
  paginateDescribeDBClusters,
  paginateDescribeDBInstances,
} from "@aws-sdk/client-rds";

const args = process.argv.slice(2);
const apply = args.includes("--apply");
const now = args.includes("--now");
const value = (name: string): string | undefined => args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
const regionArg = value("regions")?.split(",").map((r) => r.trim()).filter(Boolean);
const days = Number(value("days") ?? "7");
if (!Number.isInteger(days) || days < 1 || days > 35) throw new Error("--days must be an integer from 1 to 35");

interface Row {
  Region: string;
  Kind: "instance" | "replica" | "cluster";
  Name: string;
  Engine: string;
  Retention: number;
  Encrypted: boolean;
  DeletionProtection: boolean;
  Findings: string;
}

async function listRegions(): Promise<string[]> {
  if (regionArg) return regionArg;
  const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

function findings(kind: Row["Kind"], retention: number, encrypted: boolean, protectedFromDelete: boolean): string {
  const f: string[] = [];
  if (retention === 0 && kind !== "replica") f.push("NO AUTOMATED BACKUPS");
  else if (retention > 0 && retention < days) f.push(`retention ${retention}d < ${days}d`);
  if (!encrypted) f.push("UNENCRYPTED");
  if (!protectedFromDelete && kind !== "replica") f.push("no deletion protection");
  return f.join(", ") || "ok";
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of await listRegions()) {
    const rds = new RDSClient({ region });
    try {
      // Aurora and Multi-AZ DB clusters: retention, encryption and deletion protection live on the cluster.
      for await (const page of paginateDescribeDBClusters({ client: rds }, {})) {
        for (const c of page.DBClusters ?? []) {
          const retention = c.BackupRetentionPeriod ?? 0;
          const enc = c.StorageEncrypted === true;
          const del = c.DeletionProtection === true;
          rows.push({ Region: region, Kind: "cluster", Name: c.DBClusterIdentifier ?? "?", Engine: c.Engine ?? "?", Retention: retention, Encrypted: enc, DeletionProtection: del, Findings: findings("cluster", retention, enc, del) });
        }
      }
      for await (const page of paginateDescribeDBInstances({ client: rds }, {})) {
        for (const db of page.DBInstances ?? []) {
          if (db.DBClusterIdentifier) continue; // covered by the cluster row
          const kind: Row["Kind"] = db.ReadReplicaSourceDBInstanceIdentifier ? "replica" : "instance";
          const retention = db.BackupRetentionPeriod ?? 0;
          const enc = db.StorageEncrypted === true;
          const del = db.DeletionProtection === true;
          rows.push({ Region: region, Kind: kind, Name: db.DBInstanceIdentifier ?? "?", Engine: db.Engine ?? "?", Retention: retention, Encrypted: enc, DeletionProtection: del, Findings: findings(kind, retention, enc, del) });
        }
      }
    } catch (err) {
      rows.push({ Region: region, Kind: "instance", Name: "?", Engine: "?", Retention: 0, Encrypted: false, DeletionProtection: false, Findings: `error: ${err instanceof Error ? err.name : String(err)}` });
    }
  }

  console.table(rows);
  const noBackups = rows.filter((r) => r.Kind === "instance" && r.Findings.includes("NO AUTOMATED BACKUPS"));
  const unencrypted = rows.filter((r) => r.Findings.includes("UNENCRYPTED"));
  console.log(`${rows.length} resource(s) checked: ${noBackups.length} without automated backups, ${unencrypted.length} unencrypted.`);

  if (!apply) {
    console.log(`Report only: nothing changed. --apply would set a ${days}-day retention on ${noBackups.length} instance(s).`);
  } else {
    for (const r of noBackups) {
      try {
        // Enabling backups can cause a brief I/O suspension. Without --now it waits for the maintenance window.
        await new RDSClient({ region: r.Region }).send(
          new ModifyDBInstanceCommand({ DBInstanceIdentifier: r.Name, BackupRetentionPeriod: days, ApplyImmediately: now }),
        );
        console.log(`${r.Region} ${r.Name}: retention set to ${days} days (${now ? "applying now" : "next maintenance window"})`);
      } catch (err) {
        console.log(`${r.Region} ${r.Name}: failed (${err instanceof Error ? err.message : String(err)})`);
      }
    }
  }
  if ((noBackups.length && !apply) || unencrypted.length) process.exitCode = 2;
}

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

How do you run it?

Terminal

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

# Report every Region, treating anything under 14 days as short
AWS_PROFILE=readonly npx tsx find-rds-instances-without-automated-backups.ts --days=14

# Turn on 7-day backups at the next maintenance window
AWS_PROFILE=db-admin npx tsx find-rds-instances-without-automated-backups.ts --regions=us-east-1 --apply

# Same, but right now (brief I/O suspension)
AWS_PROFILE=db-admin npx tsx find-rds-instances-without-automated-backups.ts --regions=us-east-1 --apply --now

Sample output

Output

┌─────────┬─────────────┬────────────┬──────────────────────┬─────────────────────┬───────────┬───────────┬────────────────────┬─────────────────────────────────────────────────────────────┐
│ (index) │ Region      │ Kind       │ Name                 │ Engine              │ Retention │ Encrypted │ DeletionProtection │ Findings                                                    │
├─────────┼─────────────┼────────────┼──────────────────────┼─────────────────────┼───────────┼───────────┼────────────────────┼─────────────────────────────────────────────────────────────┤
│ 0       │ 'eu-west-1' │ 'instance' │ 'reporting-mysql'    │ 'mysql'             │ 0         │ false     │ false              │ 'NO AUTOMATED BACKUPS, UNENCRYPTED, no deletion protection' │
│ 1       │ 'eu-west-1' │ 'replica'  │ 'reporting-mysql-ro' │ 'mysql'             │ 0         │ false     │ false              │ 'UNENCRYPTED'                                               │
│ 2       │ 'us-east-1' │ 'cluster'  │ 'app-aurora'         │ 'aurora-postgresql' │ 1         │ true      │ true               │ 'retention 1d < 7d'                                         │
│ 3       │ 'us-east-1' │ 'instance' │ 'orders-pg'          │ 'postgres'          │ 14        │ true      │ true               │ 'ok'                                                        │
│ 4       │ 'us-east-1' │ 'instance' │ 'staging-pg'         │ 'postgres'          │ 0         │ true      │ false              │ 'NO AUTOMATED BACKUPS, no deletion protection'              │
└─────────┴─────────────┴────────────┴──────────────────────┴─────────────────────┴───────────┴───────────┴────────────────────┴─────────────────────────────────────────────────────────────┘
5 resource(s) checked: 2 without automated backups, 2 unencrypted.
Report only: nothing changed. --apply would set a 7-day retention on 2 instance(s).

Names are illustrative. reporting-mysql has every problem at once. Its replica shows retention 0 without a backup finding, because backups on a read replica are optional; it is still unencrypted, as a replica always matches its source. app-aurora is backed up, but only for the default single day.

What happens when you turn automated backups on?

According to the RDS API reference, enabling or disabling backups can cause a brief I/O suspension lasting from a few seconds to a few minutes, depending on the size and class of the instance. That’s why the script waits for the maintenance window by default and only passes ApplyImmediately: true when you add --now. Be aware that ApplyImmediately also applies any other modification already pending on that instance.

A few rules to know before you run it:

  • Read replica sources. The retention can’t be set to 0 on an instance that is a source for read replicas, so those never show up with backups disabled.
  • Aurora. Aurora backs up the cluster volume continuously and can’t turn automated backups off. Retention is 1 to 35 days and defaults to one day, which is why the script flags short retention on clusters.
  • Cost. Backup storage is billed per GB-month. An AWS Database Blog post on RDS backup storage costs (October 2022) states you aren’t charged for backup storage that stays within 100% of your total database storage in a Region; longer retention and manual snapshots push you past it. The script to find and delete old RDS manual snapshots is the usual way back under that allowance, and last month’s AWS cost broken down by service shows whether RDS backup storage is growing.

How do you fix an unencrypted RDS instance?

You can’t encrypt an existing DB instance in place; encryption can only be chosen at creation. The documented route is to take a snapshot, copy it with encryption enabled and a KMS key, then restore a new instance from the encrypted copy and move your application to it. That means a new endpoint and a cut-over, so the script reports unencrypted instances and leaves the plan to you. Once you depend on a customer managed key, keep it enabled; the script to find unused customer managed KMS keys helps you tell live keys from forgotten ones.

Deletion protection is a quick ModifyDBInstance or ModifyDBCluster call with DeletionProtection: true. For Aurora, set it on the cluster; instances in a protected cluster can still be deleted individually.

Troubleshooting

  • An encrypted instance is in inaccessible-encryption-credentials-recoverable. Its KMS key was disabled. Re-enable the key and start the instance within seven days, or it moves to a terminal state and can only be restored from a backup.
  • InvalidDBInstanceState. The instance is stopped or already being modified. Start it or wait, then rerun with --regions= for that Region.
  • The change doesn’t show up. Without --now, it waits for the maintenance window. PendingModifiedValues in DescribeDBInstances shows the queued retention.
  • Access denied in one Region. An SCP may block that Region. The guide to troubleshoot IAM access denied errors in AWS shows how to find the deny.

Other data stores need the same check. For EC2, the script to find EBS volumes without recent snapshots is the equivalent of this one. The scripts to enable DynamoDB point-in-time recovery on every table and find unencrypted EBS volumes and turn on default encryption follow the same pattern, and backups are only private if their snapshots are, which the script to find public EBS and RDS snapshots confirms. The live endpoint matters too: the script to find publicly accessible RDS instances shows which of the same databases can be reached from the internet.

Ask ChatWithCloud instead

You can ask ChatWithCloud “Which RDS instances have backup retention 0 or aren’t encrypted?” It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and explains what it found, one profile and Region per session. Generated code runs without a confirmation step, so connect ChatWithCloud to your AWS account with a read-only profile and make changes with this script. The guide to analyze AWS security posture with an AI CLI has more questions to try, and the ChatWithCloud security page covers what is sent where.

Frequently asked questions

How do I know if RDS automated backups are disabled?

Check BackupRetentionPeriod in DescribeDBInstances. A value of 0 means automated backups are disabled and point-in-time recovery isn’t available.

Does enabling RDS automated backups cause downtime?

It can cause a brief I/O suspension of a few seconds to a few minutes. Without ApplyImmediately, the change waits for the next maintenance window.

What is the maximum RDS backup retention period?

35 days for automated backups. For longer retention, take manual snapshots or use AWS Backup.

Can I turn on encryption for an existing RDS instance?

Not in place. Snapshot the instance, copy the snapshot with encryption, and restore a new encrypted instance from the copy.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud