Skip to content
Share on
Updated

Introduction

Amazon Relational Database Service (RDS) runs PostgreSQL for you: AWS provisions the server, applies patches, takes backups and, if you ask for it, keeps a standby ready in a second data center. What RDS doesn't decide for you is how the database fits into your network, who can log in, how much you're willing to pay for availability, and what happens when the PostgreSQL version you picked reaches the end of its support. Those decisions are the difference between a database that is merely running and one that is ready for production.

RDS is one of several ways to host PostgreSQL. This guide assumes you've chosen it and walks through setting it up the way you'd want to run it in production:

  1. the decisions to make first, and why they matter
  2. creating the database with the AWS CLI (with notes on the console equivalent)
  3. connecting securely with psql
  4. creating application roles instead of using the master user
  5. operating the database: restores, monitoring and upgrades
  6. deleting it safely

This guide was written in September 2026 for RDS for PostgreSQL 18. We checked every AWS CLI command and flag against AWS CLI 2.37.6 without an AWS account, so the AWS-side behavior described here comes from AWS's documentation, which we link throughout. The TLS connection, the SSH tunnel and the SQL were tested against local PostgreSQL containers.

Prisma Postgres

Skip the setup with Prisma Postgres

If you want Postgres without provisioning instances, networking, backups, and pooling, Prisma Postgres gives you a hosted database in seconds.

Decisions to make first

Several settings can't be changed after the database exists (encryption at rest is one), and others are cheap to get right up front but disruptive to change later. It's worth deciding them before you create anything.

RDS for PostgreSQL or Aurora PostgreSQL

AWS offers two managed PostgreSQL services. RDS for PostgreSQL runs the community PostgreSQL engine on a DB instance with its own storage volumes. Aurora PostgreSQL-Compatible Edition replaces PostgreSQL's storage layer with a cluster volume that keeps copies of your data across three Availability Zones and grows automatically.

AWS's prescriptive guidance generally recommends Aurora for its faster failover, more read replicas and backups without a performance impact, and says RDS "might still make sense for small to medium workloads" because its wider choice of instance classes can be cheaper. As a rule of thumb, start with RDS for PostgreSQL when you want plain PostgreSQL at a predictable cost, and evaluate Aurora when you need fast failover, many read replicas, or storage that grows without planning. The rest of this guide covers RDS for PostgreSQL.

PostgreSQL version

AWS makes a new major version available on RDS within 30 days of the community's first minor release (x.1) and supports each major version under standard support at least until the community's end of life, according to the RDS for PostgreSQL release calendar:

Major versionCommunity support endsRDS standard support endsRDS Extended Support ends
18November 14, 2030February 28, 2031February 28, 2034
17November 8, 2029February 28, 2030February 28, 2033
16November 9, 2028February 28, 2029February 29, 2032
15November 11, 2027February 29, 2028February 28, 2031
14November 12, 2026February 28, 2027February 28, 2030

The community dates are from PostgreSQL's versioning policy. PostgreSQL 13 and older are past RDS standard support. For a new database, choose PostgreSQL 18, the newest major version on RDS; at the time of writing, its latest minor version on RDS is 18.6.

After the end of standard support, RDS Extended Support keeps a major version running for up to three more years with fixes for critical and high-severity security issues, for an additional charge. Watch the default: the console doesn't enroll new instances, but the AWS CLI and the API enroll them unless you pass --engine-lifecycle-support open-source-rds-extended-support-disabled. Without Extended Support, RDS upgrades the instance to a supported major version on or shortly after the end-of-support date. Neither outcome is a substitute for planning your own upgrade (see Major version upgrades). You can look up the dates for a major version with the CLI:

aws rds describe-db-major-engine-versions --engine postgres --major-engine-version 18

Instance class

The DB instance class sets the CPU, memory and network bandwidth of the server. AWS lists these families among its instance class types:

  • General purpose (db.m9g, db.m8g, db.m7g): balanced CPU and memory on AWS Graviton5, Graviton4 and Graviton3 processors. A good default for production. db.m7i (Intel) and db.m8a (AMD) are x86 alternatives.
  • Memory optimized (db.r8g, db.r7g): more memory per vCPU, for databases whose frequently used data and indexes don't fit in the memory of an m class.
  • Optimized Reads (db.m8gd, db.r8gd): Graviton4 classes with local NVMe SSD storage in addition to the EBS volumes.
  • Burstable (db.t4g): a low baseline CPU with the ability to burst. RDS runs db.t4g instances in Unlimited mode, so sustained bursting is billed as extra. They suit development, staging and small, spiky workloads, not steady production load. For PostgreSQL 18, check their availability first, as explained below.

Graviton classes use Arm processors, which makes no difference to your application: clients talk to PostgreSQL over the network either way. Memory usually matters more than CPU for a relational database, because data that PostgreSQL finds in memory doesn't have to be read from storage. Start with the smallest size of a general-purpose class that fits your data, watch the metrics described in Monitoring, and change the class when the numbers tell you to. Changing it is a single modify-db-instance call, but the database is unavailable while the change is applied, so schedule it.

Not every class is available for every engine version and Region, and PostgreSQL 18 narrows the choice for now. At the time of writing, AWS's table of supported engine versions per class lists PostgreSQL 18 only for db.m9g, db.m8gd and db.r8gd. For db.m8g, db.r8g, db.m7g, db.r7g, db.m7i and db.t4g, it lists PostgreSQL 17 and older. The table may lag behind what RDS offers, but don't assume: if you want PostgreSQL 18 on one of those classes, run the availability check shown in Creating the database first. This guide's example uses db.m9g.large.

Storage

RDS for PostgreSQL stores data on Amazon EBS volumes. For most databases, the storage type decision is simple:

  • General Purpose SSD (gp3) is the default and a sensible starting point. AWS describes General Purpose storage as best suited for development and testing and recommends Provisioned IOPS for production workloads that need fast, consistent I/O, but many production databases run well on gp3 until their latency metrics say otherwise. Below 400 GiB, a gp3 volume delivers a baseline of 3,000 IOPS and 125 MiB/s. From 400 GiB, RDS stripes the data across four volumes, the baseline rises to 12,000 IOPS and 500 MiB/s, and you can pay for more IOPS and throughput independently of the size.
  • Provisioned IOPS SSD (io2 Block Express) is for I/O-heavy, latency-sensitive workloads that need consistent sub-millisecond latency. You pay for the IOPS you provision whether you use them or not, so choose it when your metrics show that gp3 isn't enough. io1 is the previous generation; AWS recommends io2 where it's available.
  • Magnetic storage is deprecated and can no longer be used for new instances.

Turn on storage autoscaling by setting a maximum storage threshold. RDS then grows the volume when free space stays at or below 10% for five minutes, which protects you from a classic cause of outages: a full disk. Set the threshold to what you're willing to pay for, and keep in mind that storage can grow but never shrink.

Availability: Single-AZ, Multi-AZ DB instance, or Multi-AZ DB cluster

An Availability Zone (AZ) is one or more data centers in an AWS Region. RDS offers three deployment options that differ in how they survive the loss of an instance or an AZ:

DeploymentStandbysStandbys serve readsTypical failover
Single-AZ DB instanceNoneNot applicableNo failover: recovery means a restore or replacement
Multi-AZ DB instanceOne, in another AZ, synchronously replicatedNoTypically 60–120 seconds
Multi-AZ DB clusterTwo, in two other AZs, semisynchronousYesTypically under 35 seconds

A Multi-AZ DB instance is the usual choice for production: you pay for a second instance that you can't query, and in return a failed instance or AZ costs you a minute or two instead of a restore. It also shortens maintenance, because RDS patches the operating system on the standby first and then fails over. A failover keeps the endpoint's name but points its DNS record at the standby's IP address, so clients have to reconnect and must not cache DNS lookups for long: AWS recommends a DNS TTL of no more than 60 seconds for the Java virtual machine (networkaddress.cache.ttl), because some JVM configurations never refresh cached DNS entries until the JVM restarts. See failing over a Multi-AZ DB instance.

A Multi-AZ DB cluster fails over faster and lets you send read-only queries to the standbys, but it's a different resource type (created with create-db-cluster), it runs only on classes with local NVMe storage such as db.m6gd, db.m8gd and db.r8gd, and it doesn't support the Blue/Green deployments described later for major version upgrades.

A Single-AZ instance is fine for development and staging, and for production workloads that can tolerate the time it takes to restore from a backup.

Network: keep the database private

Every RDS instance lives in a virtual private cloud (VPC), a private network in your AWS account. Three settings control who can reach it:

  • A DB subnet group lists the subnets RDS may place the instance in. Use private subnets (subnets without a route to an internet gateway) in at least two AZs, even for a Single-AZ instance, so that you can switch to Multi-AZ later.
  • A VPC security group acts as a firewall. Allow PostgreSQL's port only from the security groups of the resources that need it, such as your application servers, instead of from IP ranges.
  • Public access decides whether the instance gets a public IP address. Turn it off. With the CLI you have to do this explicitly, because the default depends on which subnet group you use.

Without public access, only resources inside the VPC (or networks connected to it) can reach the database. To reach it from your own computer, you connect through something inside the VPC: port forwarding with AWS Systems Manager Session Manager, a bastion host you connect to with SSH, or a VPN or AWS Direct Connect link into the VPC. An internet gateway doesn't provide this: it only routes traffic for resources that have public IP addresses, and a database without public access has none. AWS describes the common setups in scenarios for accessing a DB instance in a VPC. If you don't have a VPC with private subnets yet, AWS has a tutorial for creating one.

Credentials and authentication

RDS creates one administrative login, the master user. It isn't a PostgreSQL superuser: it's a member of the rds_superuser role, which can create databases, roles and extensions, while AWS keeps the real superuser to itself. Use the master user for administration only, and give applications their own roles (see First steps).

For the master password, let RDS manage it in AWS Secrets Manager. RDS generates the password, stores it in a secret that you control access to with IAM, and rotates it every seven days by default, so the password never has to appear in a script, a chat message or your shell history. The secret is billed at Secrets Manager's rates and is deleted with the instance. Of the features that AWS lists as not working with a managed master password, two apply to RDS for PostgreSQL: creating read replicas and Blue/Green deployments. You can switch the integration off and on later with modify-db-instance.

RDS can also accept IAM database authentication: instead of a password, a database role logs in with a token that is signed with AWS credentials and valid for 15 minutes. This is useful for people and tools that already have IAM identities, and is covered later.

Encryption

Turn on encryption at rest when you create the instance, because you can't add it later without restoring from an encrypted copy of a snapshot. It covers the storage, logs, automated backups and snapshots. The CLI doesn't encrypt by default, so pass --storage-encrypted. RDS then uses the AWS managed key for RDS, or a customer managed AWS KMS key if you pass --kms-key-id. You can't change the key afterwards.

In transit, RDS for PostgreSQL 15 and later require TLS by default: the rds.force_ssl parameter defaults to 1, and connections without TLS are rejected. (On PostgreSQL 14 it defaults to 0.) PostgreSQL 16 and later also support TLS 1.3. RDS signs each instance's server certificate with a certificate authority (CA) that you can choose; the default is rds-ca-rsa2048-g1, and RDS rotates the server certificate before it expires. Requiring TLS only stops eavesdropping, though. To also make sure that you're talking to your database and not an impostor, clients must verify the certificate, which you'll set up in Connecting securely.

Parameter groups

PostgreSQL's configuration (the settings you'd put in postgresql.conf on your own server) lives in a DB parameter group. The default group can't be modified, and switching an existing instance to a new group requires a reboot, so create a custom group for each major version from the start, even if you don't change anything yet.

Backups

With automated backups turned on, RDS takes a daily snapshot of the instance's storage during a backup window and also keeps its transaction logs, so you can restore the instance to any point in time within the retention period, not only to the moment a snapshot was taken. Backups are stored in Amazon S3 (managed by RDS, not in a bucket you can browse), and backup storage is billed as described on the RDS pricing page.

The retention period can be 0 to 35 days (0 turns automated backups off), and the CLI's default is 1 day, which is too short to notice most mistakes. A 14-day retention gives you two weeks to discover a bad deployment or an accidental DELETE; longer periods cost more storage. Copy the instance's tags to its snapshots so that they are easier to find and attribute costs to, and consider replicating backups to another Region if you need to recover from the loss of a whole Region.

Cost

An RDS bill has more parts than the instance price. Expect charges for:

  • instance hours, doubled for a Multi-AZ DB instance (the standby is a full instance)
  • allocated storage, plus provisioned IOPS and throughput if you add them
  • backup storage and snapshots
  • RDS Extended Support, if an instance runs past standard support
  • the Secrets Manager secret, CloudWatch Logs for exported logs, and Database Insights retention beyond the free tier
  • data transfer, depending on where the traffic goes

Check the RDS for PostgreSQL pricing page for your Region, estimate your setup with the AWS Pricing Calculator, and consider reserved instances once the size is stable.

Creating the database with the AWS CLI

You need AWS CLI version 2, credentials for an IAM identity that may create RDS, EC2 security group and Secrets Manager resources, and a VPC with private subnets in at least two AZs. The commands work in Bash and zsh. Start by setting the values that are specific to your account: the Region, the VPC, its private subnets, and the security groups of your application servers (APP_SG) and of your bastion or Session Manager host (BASTION_SG):

export AWS_REGION=eu-central-1
VPC_ID=vpc-0123456789abcdef0
SUBNET_IDS=(subnet-0aaaaaaaaaaaaaaaa subnet-0bbbbbbbbbbbbbbbb subnet-0cccccccccccccccc)
APP_SG=sg-0aaaaaaaaaaaaaaaa
BASTION_SG=sg-0bbbbbbbbbbbbbbbb

Create the security group and subnet group

Create a security group for the database and allow PostgreSQL's port only from the application and the bastion:

DB_SG=$(aws ec2 create-security-group \
--group-name app-prod-db \
--description "PostgreSQL for app-prod" \
--vpc-id "$VPC_ID" \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$DB_SG" \
--protocol tcp --port 5432 --source-group "$APP_SG"
aws ec2 authorize-security-group-ingress --group-id "$DB_SG" \
--protocol tcp --port 5432 --source-group "$BASTION_SG"

Then tell RDS which private subnets it may use:

aws rds create-db-subnet-group \
--db-subnet-group-name app-prod-db \
--db-subnet-group-description "Private subnets for app-prod-db" \
--subnet-ids "${SUBNET_IDS[@]}"

Create a parameter group

Create a custom parameter group for PostgreSQL 18. Its family, postgres18, has to match the major version:

aws rds create-db-parameter-group \
--db-parameter-group-name app-prod-pg18 \
--db-parameter-group-family postgres18 \
--description "app-prod PostgreSQL 18"
aws rds modify-db-parameter-group \
--db-parameter-group-name app-prod-pg18 \
--parameters \
"ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=pending-reboot" \
"ParameterName=log_min_duration_statement,ParameterValue=1000,ApplyMethod=pending-reboot"

rds.force_ssl is already 1 by default on PostgreSQL 15 and later; setting it explicitly documents the intent. log_min_duration_statement logs every statement that takes longer than 1,000 milliseconds, which is the cheapest way to find slow queries. Be aware that logged statements can contain data from your tables. The pending-reboot apply method works for both static and dynamic parameters, and since no instance uses the group yet, the values simply take effect when the instance is created. If you want to go further, AWS describes how to refuse MD5 password hashes entirely with rds.accepted_password_auth_method; new passwords on PostgreSQL 14 and later are stored as SCRAM hashes by default anyway.

To see which parameter group family and minor versions RDS offers for a major version, run:

aws rds describe-db-engine-versions --engine postgres \
--query "DBEngineVersions[?MajorEngineVersion=='18'].[EngineVersion,DBParameterGroupFamily]" \
--output text

Check that the instance class is available

Instance classes vary by Region and engine version. This command prints the storage types you can use with db.m9g.large and PostgreSQL 18.6, and whether Multi-AZ is available. If it prints nothing, the class isn't offered for that version in your Region, so pick another one:

aws rds describe-orderable-db-instance-options \
--engine postgres --engine-version 18.6 \
--db-instance-class db.m9g.large \
--query "OrderableDBInstanceOptions[].[StorageType,MultiAZCapable]" \
--output text

Create the DB instance

Now create the instance. Every flag here is either a production decision from the previous section or a default you'd otherwise have to remember to change:

aws rds create-db-instance \
--db-instance-identifier app-prod-db \
--engine postgres \
--engine-version 18.6 \
--db-instance-class db.m9g.large \
--multi-az \
--storage-type gp3 \
--allocated-storage 100 \
--max-allocated-storage 500 \
--storage-encrypted \
--db-subnet-group-name app-prod-db \
--vpc-security-group-ids "$DB_SG" \
--no-publicly-accessible \
--db-parameter-group-name app-prod-pg18 \
--ca-certificate-identifier rds-ca-rsa2048-g1 \
--master-username dbadmin \
--manage-master-user-password \
--db-name appdb \
--backup-retention-period 14 \
--preferred-backup-window 02:00-02:30 \
--preferred-maintenance-window sun:03:00-sun:03:30 \
--copy-tags-to-snapshot \
--auto-minor-version-upgrade \
--database-insights-mode standard \
--enable-performance-insights \
--performance-insights-retention-period 7 \
--enable-cloudwatch-logs-exports postgresql upgrade \
--deletion-protection \
--tags Key=app,Value=app-prod

What the flags do:

  • --engine-version 18.6 pins the minor version you tested with. --auto-minor-version-upgrade then lets RDS move the instance to newer minor versions during the maintenance window.
  • --multi-az creates a Multi-AZ DB instance with a standby. Leave it out for a Single-AZ instance.
  • --storage-type gp3 --allocated-storage 100 --max-allocated-storage 500 starts with 100 GiB and lets storage autoscaling grow it up to 500 GiB. Below 400 GiB, you don't pass --iops for gp3, because the baseline performance can't be changed at that size.
  • --storage-encrypted turns on encryption at rest with the AWS managed key.
  • --db-subnet-group-name, --vpc-security-group-ids and --no-publicly-accessible place the instance in your private subnets behind its own security group.
  • --ca-certificate-identifier rds-ca-rsa2048-g1 sets the CA that signs the server certificate. It's the current default, and the RDS certificate bundle you'll download contains it.
  • --master-username dbadmin --manage-master-user-password creates the master user with a password that RDS generates and stores in Secrets Manager. --db-name appdb creates a database for your application, in addition to the postgres database that always exists.
  • --backup-retention-period 14 keeps 14 days of point-in-time recovery. The backup and maintenance windows are in UTC, must not overlap, and should fall in your quietest hours.
  • --database-insights-mode standard --enable-performance-insights --performance-insights-retention-period 7 turns on CloudWatch Database Insights in Standard mode with the 7 days of query-level history that are included at no charge. The flags still carry the name of Performance Insights, the feature Database Insights replaced.
  • --enable-cloudwatch-logs-exports postgresql upgrade sends the PostgreSQL log (including the slow statements configured above) and the upgrade log to CloudWatch Logs, where you pay for ingestion and storage.
  • --deletion-protection makes delete-db-instance fail until you turn it off.

The command doesn't pass --engine-lifecycle-support, so the instance is enrolled in RDS Extended Support, as described in PostgreSQL version. Creating the instance takes a while. Wait for it (the waiter gives up after 30 minutes; run it again if that happens), then read the instance's endpoint and the ARN of the master user's secret:

aws rds wait db-instance-available --db-instance-identifier app-prod-db
DB_HOST=$(aws rds describe-db-instances --db-instance-identifier app-prod-db \
--query "DBInstances[0].Endpoint.Address" --output text)
SECRET_ARN=$(aws rds describe-db-instances --db-instance-identifier app-prod-db \
--query "DBInstances[0].MasterUserSecret.SecretArn" --output text)

The console equivalent

You can make the same choices in the RDS console with Create database. We couldn't test the console for this guide, so the labels below are the ones AWS's documentation uses, and the screens may differ. Look for Manage master credentials in AWS Secrets Manager in the credential settings, the Maximum storage threshold under Storage autoscaling, Public access under Additional connectivity configuration, the Certificate authority setting, Enable encryption, and the Database Insights section. Two defaults differ from the CLI: the console turns on deletion protection by default, and it doesn't enroll the instance in Extended Support unless you select Enable RDS Extended Support. AWS's guide to creating a DB instance describes each setting.

Connecting securely with psql

Your application servers inside the VPC can connect to $DB_HOST directly. From your own computer, you first need a way into the VPC, and in both cases the client should verify the server's certificate. The steps below use psql. Drivers built on libpq accept the same connection parameters, and most other drivers have equivalent TLS settings.

Download the RDS certificate bundle

Download AWS's certificate bundle, which contains the root CA certificates for all commercial Regions:

curl -sSfLo global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem

It's a PEM file with many certificates in it. psql accepts the whole file as its list of trusted root CAs. AWS also publishes a bundle for each Region on the certificate bundle page. Trust only the root certificates, not intermediate ones, so that automatic rotation of the server certificate doesn't break your connections.

Open a tunnel into the VPC

With Session Manager port forwarding, an EC2 instance in the VPC relays the connection, and neither the instance nor the database needs an inbound rule for your IP address. The instance needs SSM Agent 3.1.1374.0 or later and permission to be managed by Systems Manager, and your computer needs the Session Manager plugin for the AWS CLI:

aws ssm start-session \
--target i-0123456789abcdef0 \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters "{\"host\":[\"$DB_HOST\"],\"portNumber\":[\"5432\"],\"localPortNumber\":[\"15432\"]}"

If you use a bastion host with SSH instead, forward a local port through it:

ssh -N -L 127.0.0.1:15432:"$DB_HOST":5432 ec2-user@bastion.example.com

Either way, port 15432 on your computer now leads to port 5432 on the database. The examples use 15432 to stay out of the way of a PostgreSQL server you might run locally, and the SSH command binds it to 127.0.0.1 so that other machines on your network can't use your tunnel. Leave the command running and continue in a second terminal.

Connect with certificate verification

Read the master password from Secrets Manager (this uses jq to pick the password field out of the secret's JSON) and pass it to psql in the same command, so it isn't left behind in your shell's environment:

PGPASSWORD="$(aws secretsmanager get-secret-value --secret-id "$SECRET_ARN" \
--query SecretString --output text | jq -r .password)" \
psql "host=$DB_HOST hostaddr=127.0.0.1 port=15432 dbname=appdb user=dbadmin sslmode=verify-full sslrootcert=global-bundle.pem"

Two parameters do the security work. sslmode=verify-full makes psql check that the server's certificate was signed by a CA in sslrootcert and that it was issued for the host name in host. Weaker modes such as require encrypt the connection but normally don't check the certificate, so they would also accept one presented by an attacker in the middle. And the host name is the reason for hostaddr: through a tunnel, the network connection goes to 127.0.0.1, but the certificate is issued for the RDS endpoint. hostaddr tells psql where to connect, while host stays the name that the certificate must match.

We tested this pattern with PostgreSQL 18.6 behind an SSH bastion in local containers. Because a local server can't have a certificate from AWS, we signed its certificate with our own test CA and appended that CA to the downloaded RDS bundle; against RDS, the bundle alone is what you need. In that test, the stand-in for the RDS endpoint was db.dg-rw-rds.test. Connecting to the tunnel with host=127.0.0.1 and no hostaddr fails verification, as it should:

psql: error: connection to server at "127.0.0.1", port 15432 failed: server certificate for "db.dg-rw-rds.test" (and 1 other name) does not match host name "127.0.0.1"

With hostaddr, the connection succeeds, and psql's banner confirms the encryption:

SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off, ALPN: postgresql)

RDS rotates the master password every seven days, so read it from the secret each time instead of copying it somewhere.

First steps: roles for your application

The master user can create and drop databases and roles, so an application that logs in as the master user can do the same if it's compromised or has a bug. Create two roles instead: app_migrator, which owns the schema and runs your migrations, and app_user, which the application uses at runtime and which can only read and write rows. Connected to appdb as the master user, run:

CREATE ROLE app_migrator LOGIN;
CREATE ROLE app_user LOGIN;
\password app_migrator
\password app_user
-- Only these two roles (and the master user) may connect to appdb
REVOKE ALL ON DATABASE appdb FROM PUBLIC;
GRANT CONNECT ON DATABASE appdb TO app_migrator, app_user;
-- Since PostgreSQL 16, creating a role doesn't make you a member of it.
-- Membership lets the master user create the schema on app_migrator's behalf.
GRANT app_migrator TO CURRENT_USER;
CREATE SCHEMA app AUTHORIZATION app_migrator;
GRANT USAGE ON SCHEMA app TO app_user;
-- Tables and sequences that app_migrator creates later are usable by app_user
ALTER DEFAULT PRIVILEGES FOR ROLE app_migrator IN SCHEMA app
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_migrator IN SCHEMA app
GRANT USAGE, SELECT ON SEQUENCES TO app_user;
ALTER ROLE app_migrator IN DATABASE appdb SET search_path = app;
ALTER ROLE app_user IN DATABASE appdb SET search_path = app;

\password is a psql command that prompts for the new password and sends only its SCRAM hash to the server, so the password doesn't end up in the server log or your shell history. Store the two passwords where your deployment reads its secrets, for example in secrets of your own in Secrets Manager. The default privileges are the part that's easy to forget: without them, app_user couldn't use tables that app_migrator creates in the future. If you already created objects in the schema, grant access to them with GRANT ... ON ALL TABLES IN SCHEMA app.

We ran these statements as a non-superuser role with CREATEDB and CREATEROLE, the attributes RDS gives the master user, on PostgreSQL 15, 16, 17 and 18. After app_migrator had created a table, app_user could insert and select rows but not change the schema:

CREATE TABLE app.scratch (id int);
ERROR: permission denied for schema app
LINE 1: CREATE TABLE app.scratch (id int);
^

Point your migrations at app_migrator and your application at app_user, both with sslmode=verify-full. If your application opens many short-lived connections, for example from serverless functions, consider Amazon RDS Proxy: it pools and reuses database connections, protects the database from connection surges, and reconnects to the standby after a failover while preserving application connections. It presents its own certificate from AWS Certificate Manager, so clients that connect through a proxy don't need the RDS certificate bundle. The Data Guide's articles on role management and managing privileges explain the underlying concepts.

Optional: IAM database authentication

For people who need to look at the database occasionally, IAM database authentication avoids another set of passwords. Turn it on for the instance:

aws rds modify-db-instance --db-instance-identifier app-prod-db \
--enable-iam-database-authentication --apply-immediately

Then create a role that logs in with IAM and grant it what it needs, here read-only access to the application's tables:

CREATE ROLE ops_reader LOGIN;
GRANT rds_iam TO ops_reader;
GRANT CONNECT ON DATABASE appdb TO ops_reader;
GRANT USAGE ON SCHEMA app TO ops_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO ops_reader;

Don't grant rds_iam to app_user or the master user: a role with rds_iam can only log in with IAM, not with its password. An IAM user or role may connect as ops_reader if one of its policies allows the rds-db:connect action on a resource of the form arn:aws:rds-db:REGION:ACCOUNT_ID:dbuser:DBI_RESOURCE_ID/ops_reader, as described in AWS's policy guide. From inside the VPC, a connection then looks like this:

PGPASSWORD="$(aws rds generate-db-auth-token \
--hostname "$DB_HOST" --port 5432 --region "$AWS_REGION" --username ops_reader)" \
psql "host=$DB_HOST port=5432 dbname=appdb user=ops_reader sslmode=verify-full sslrootcert=global-bundle.pem"

The token is valid for 15 minutes and is only checked when the connection is opened. AWS notes that IAM authentication uses between 300 and 1,000 MiB of extra memory on the instance, which matters on small instance classes.

Operating the database

Backups and point-in-time recovery

A point-in-time restore never overwrites your instance: it creates a new one from the backups, which you then inspect, copy data from, or switch your application to. First, check how far forward you can restore. RDS uploads transaction logs every five minutes, so the latest restorable time is usually a few minutes in the past:

aws rds describe-db-instances --db-instance-identifier app-prod-db \
--query "DBInstances[0].LatestRestorableTime" --output text

Then restore to the moment before the mistake, in UTC:

aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier app-prod-db \
--target-db-instance-identifier app-prod-db-restore \
--restore-time 2026-09-30T09:15:00Z \
--db-subnet-group-name app-prod-db \
--vpc-security-group-ids "$DB_SG" \
--db-parameter-group-name app-prod-pg18 \
--no-publicly-accessible \
--deletion-protection

Pass the network settings and the parameter group explicitly, because a restored instance otherwise gets the VPC's default security group and the default parameter group. It gets the source's instance class unless you pass --db-instance-class. For RDS for PostgreSQL, the restore can't turn on Secrets Manager management of the master password (AWS supports that only for Oracle). If the restored instance has no MasterUserSecret, run modify-db-instance --manage-master-user-password --apply-immediately on it to get a new managed password. AWS's restore guide lists the remaining options.

Automated backups disappear with the instance by default, so take a manual snapshot before risky changes and before deleting anything. Manual snapshots are kept until you delete them:

aws rds create-db-snapshot \
--db-instance-identifier app-prod-db \
--db-snapshot-identifier app-prod-db-before-schema-change

Test restores regularly. A backup you've never restored is a hope, not a plan; the Data Guide's article on backup considerations covers the broader strategy.

Monitoring

RDS publishes CloudWatch metrics for every instance. Set CloudWatch alarms on at least these:

  • FreeStorageSpace: autoscaling helps, but it can't keep up with every bulk load, and it stops at your threshold.
  • CPUUtilization and FreeableMemory: sustained high CPU or low memory means it's time for a bigger class or better queries. On db.t4g, also watch CPUCreditBalance.
  • DatabaseConnections: every PostgreSQL connection uses memory, and running out of connections looks like an outage to your application.
  • ReadLatency, WriteLatency and DiskQueueDepth: rising values mean that storage has become the bottleneck.
  • MaximumUsedTransactionIDs: PostgreSQL-specific. If autovacuum falls far behind, transaction ID wraparound can eventually force the database to stop accepting writes.

For query-level analysis, RDS uses CloudWatch Database Insights. AWS ended Performance Insights on July 31, 2026, and moved its users to Database Insights, which shows database load by wait event, SQL statement, host and user. Standard mode, the default, keeps 7 days of detailed metrics at no extra cost. Advanced mode adds features such as fleet-wide views, lock analysis for PostgreSQL and per-query statistics, and is billed separately; you can switch modes without downtime. For operating system metrics per process, turn on Enhanced Monitoring, which needs an IAM role that RDS can use to publish them.

Finally, subscribe to RDS event notifications for failovers, maintenance and low storage, so that you hear about them from AWS before you hear about them from your users.

Maintenance and minor version upgrades

RDS applies operating system and engine patches during the weekly maintenance window. Required patches are infrequent and can be deferred, but not indefinitely. With a Multi-AZ DB instance, RDS patches the operating system on the standby first and then fails over, so the interruption is usually a failover rather than a full outage. Engine version upgrades are different: RDS upgrades the primary and the standby at the same time, so even a Multi-AZ instance is unavailable during an engine upgrade.

With automatic minor version upgrades on, RDS moves the instance to the minor version it has designated as the automatic upgrade target for that major version once it has been tested, not to every new minor release. To see what's scheduled, run:

aws rds describe-pending-maintenance-actions \
--filters Name=db-instance-id,Values=app-prod-db

Engine upgrades don't upgrade extensions. After an upgrade, compare installed_version and default_version in the pg_available_extensions view, and update extensions with ALTER EXTENSION ... UPDATE.

Major version upgrades

Major version upgrades change PostgreSQL's on-disk format and can change behavior, so test them before production does. An in-place upgrade (modify-db-instance --engine-version ... --allow-major-version-upgrade) is the simplest route but takes the database offline for the duration of the upgrade. Blue/Green deployments reduce that to a switchover that "typically takes under a minute": RDS creates a copy of your instance (green), keeps it in sync with production (blue), and lets you upgrade and test the green instance before swapping the endpoints.

For a major version upgrade, RDS keeps the green instance in sync with PostgreSQL's logical replication, which comes with requirements and limitations:

  • The blue instance needs a custom parameter group with rds.logical_replication set to 1, and a reboot for the change to take effect.
  • Every table needs a primary key, because logical replication can't replicate UPDATE and DELETE otherwise.
  • Schema changes (DDL) and large objects aren't replicated. Freeze migrations while the deployment exists.
  • Blue/Green deployments don't support master passwords managed in Secrets Manager, so you have to switch to a password you manage for the duration (shown below).
  • After switchover, point-in-time recovery for the new production instance starts from when the green instance was created. Keep the old instance until your recovery window has passed.

Before you create the deployment, replace the managed master password with one of your own. read -rs reads it without echoing it or recording it in your shell history. Use a long, generated password and store it in a secret of your own until you turn the integration back on, because RDS deletes its managed secret when you turn management off:

read -rs NEW_MASTER_PASSWORD
aws rds modify-db-instance --db-instance-identifier app-prod-db \
--no-manage-master-user-password \
--master-user-password "$NEW_MASTER_PASSWORD" \
--apply-immediately

The password is still visible in the process list while the aws command runs, so run it interactively rather than from a script that records its arguments, and rotate the password afterwards if that matters in your environment.

When a new major version is available on RDS, create a parameter group for its family as shown earlier, set TARGET_VERSION to the engine version you want (for example, from describe-db-engine-versions) and TARGET_PARAMETER_GROUP to the new group's name. Then create the deployment, test the green instance, and switch over:

DB_ARN=$(aws rds describe-db-instances --db-instance-identifier app-prod-db \
--query "DBInstances[0].DBInstanceArn" --output text)
BGD_ID=$(aws rds create-blue-green-deployment \
--blue-green-deployment-name app-prod-major-upgrade \
--source "$DB_ARN" \
--target-engine-version "$TARGET_VERSION" \
--target-db-parameter-group-name "$TARGET_PARAMETER_GROUP" \
--query BlueGreenDeployment.BlueGreenDeploymentIdentifier --output text)
aws rds switchover-blue-green-deployment \
--blue-green-deployment-identifier "$BGD_ID" \
--switchover-timeout 300

If the switchover doesn't finish within the timeout (300 seconds is also the default), RDS rolls it back and leaves both environments unchanged. During the switchover, RDS renames the instances so that the green one takes over the endpoint app-prod-db, and the old one becomes app-prod-db-old1. Your application reconnects to the same host name without configuration changes, but the name now resolves to a different IP address. AWS recommends that your network and client configuration don't cache DNS for longer than five seconds, the TTL of RDS's DNS records, or applications keep sending writes to the old instance; see AWS's switchover best practices. RDS Proxy avoids the DNS dependency: AWS notes that it shortens switchover downtime by redirecting connections without waiting for DNS propagation.

Once the switchover is done, delete the deployment object (RDS keeps both the new and the old instance) and turn the Secrets Manager integration back on for the new production instance, which has taken over the name app-prod-db:

aws rds delete-blue-green-deployment --blue-green-deployment-identifier "$BGD_ID"
aws rds modify-db-instance --db-instance-identifier app-prod-db \
--manage-master-user-password --apply-immediately

Deleting the database

Deletion protection has to be turned off first, which is the point of it: deleting a production database should take two deliberate steps.

aws rds modify-db-instance --db-instance-identifier app-prod-db \
--no-deletion-protection --apply-immediately
aws rds delete-db-instance --db-instance-identifier app-prod-db \
--final-db-snapshot-identifier app-prod-db-final

Unless you took manual snapshots, the final snapshot is your only way back: by default, RDS deletes the instance's automated backups along with it (pass --no-delete-automated-backups to keep them for the rest of their retention period). The master user's secret is deleted too, so if you ever restore the final snapshot, set a new master password or turn on the Secrets Manager integration again on the restored instance.

Snapshots, retained backups, the parameter group, the subnet group and the security group stay behind. Snapshots and retained backups cost money until you delete them, so decide how long you need the final snapshot and put a reminder in your calendar.

Conclusion

A production-ready RDS for PostgreSQL instance comes down to a handful of decisions made early: a supported major version with a plan for its end of life, a Multi-AZ deployment, encrypted gp3 storage with autoscaling, a private network with narrow security group rules, a master password managed by Secrets Manager, and backups kept long enough to notice mistakes. After that, connect with sslmode=verify-full, give your application least-privilege roles, and keep an eye on storage, memory and the upgrade calendar.

Create a hosted Postgres database

Provision a production-ready Postgres database in seconds — no instances, networking, or backups to manage.